From 6a8dc11e639e03d7371f5117d0f1346996599a87 Mon Sep 17 00:00:00 2001 From: Caspian Zhao Date: Sun, 24 Nov 2024 17:21:51 +0800 Subject: [PATCH] chore: add tests --- .gitignore | 6 +- jest.config.js | 5 +- src/components/Carousel.test.tsx | 427 ++++++++++++++++++ src/hooks/useAutoPlay.test.ts | 194 ++++++++ src/hooks/useCarouselController.test.tsx | 248 ++++++++++ src/hooks/useCheckMounted.test.ts | 47 ++ src/hooks/useInitProps.test.tsx | 134 ++++++ src/hooks/useLayoutConfig.test.tsx | 247 ++++++++++ src/hooks/useOnProgressChange.test.tsx | 173 +++++++ src/hooks/usePanGestureProxy.ts | 2 +- src/hooks/useUpdateGestureConfig.test.ts | 95 ++++ src/types.ts | 18 + .../computed-with-auto-fill-data.test.ts | 212 +++++++++ src/utils/deal-with-animation.test.ts | 99 ++++ src/utils/log.test.ts | 67 +++ jest-setup.js => test/jest-setup.js | 0 test/reporter.js | 21 + 17 files changed, 1992 insertions(+), 3 deletions(-) create mode 100644 src/components/Carousel.test.tsx create mode 100644 src/hooks/useAutoPlay.test.ts create mode 100644 src/hooks/useCarouselController.test.tsx create mode 100644 src/hooks/useCheckMounted.test.ts create mode 100644 src/hooks/useInitProps.test.tsx create mode 100644 src/hooks/useLayoutConfig.test.tsx create mode 100644 src/hooks/useOnProgressChange.test.tsx create mode 100644 src/hooks/useUpdateGestureConfig.test.ts create mode 100644 src/utils/computed-with-auto-fill-data.test.ts create mode 100644 src/utils/deal-with-animation.test.ts create mode 100644 src/utils/log.test.ts rename jest-setup.js => test/jest-setup.js (100%) create mode 100644 test/reporter.js diff --git a/.gitignore b/.gitignore index 30aed914..0cac3861 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,8 @@ lib/ !.yarn/plugins !.yarn/releases !.yarn/sdks -!.yarn/versions \ No newline at end of file +!.yarn/versions + +# Cursor +.cursorrules +.cursorignore \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 757fd79d..5d682292 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,10 +9,13 @@ module.exports = { ".eslintrc", ], setupFiles: [ - "./jest-setup.js", + "./test/jest-setup.js", "./node_modules/react-native-gesture-handler/jestSetup.js", ], setupFilesAfterEnv: ["@testing-library/jest-native/extend-expect"], testEnvironment: "node", transformIgnorePatterns: [], + reporters: [ + "./test/reporter.js", + ], }; diff --git a/src/components/Carousel.test.tsx b/src/components/Carousel.test.tsx new file mode 100644 index 00000000..7a6933cb --- /dev/null +++ b/src/components/Carousel.test.tsx @@ -0,0 +1,427 @@ +import type { FC } from "react"; +import React from "react"; +import type { PanGesture } from "react-native-gesture-handler"; +import { Gesture, State } from "react-native-gesture-handler"; +import Animated, { + useDerivedValue, + useSharedValue, +} from "react-native-reanimated"; +import type { ReactTestInstance } from "react-test-renderer"; + +import { render, waitFor } from "@testing-library/react-native"; +import { + fireGestureHandler, + getByGestureTestId, +} from "react-native-gesture-handler/jest-utils"; + +import Carousel from "./Carousel"; + +import type { TCarouselProps } from "../types"; + +jest.setTimeout(1000 * 12); + +const mockPan = jest.fn(); +const realPan = Gesture.Pan(); +const gestureTestId = "rnrc-gesture-handler"; + +jest.spyOn(Gesture, "Pan").mockImplementation(() => { + mockPan(); + return realPan.withTestId(gestureTestId); +}); + +describe("Test the real swipe behavior of Carousel to ensure it's working as expected", () => { + const slideWidth = 300; + const slideHeight = 200; + const slideCount = 4; + + beforeEach(() => { + mockPan.mockClear(); + }); + + // Helper function to create mock data + const createMockData = (length: number = slideCount) => + Array.from({ length }, (_, i) => `Item ${i + 1}`); + + // Helper function to create default props with correct typing + const createDefaultProps = ( + progressAnimVal: Animated.SharedValue, + customProps: Partial> = {}, + ) => { + const baseProps: Partial> = { + width: slideWidth, + height: slideHeight, + data: createMockData(), + defaultIndex: 0, + testID: "carousel-swipe-container", + onProgressChange: progressAnimVal, + }; + + return { + ...baseProps, + ...customProps, + } as TCarouselProps; + }; + + // Helper function to create test wrapper + const createWrapper = (progress: { current: number }) => { + const Wrapper: FC>> = React.forwardRef( + (customProps, ref) => { + const progressAnimVal = useSharedValue(progress.current); + const defaultRenderItem = ({ + item, + index, + }: { + item: string; + index: number; + }) => ( + + {item} + + ); + const { renderItem = defaultRenderItem, ...defaultProps } = + createDefaultProps(progressAnimVal, customProps); + + useDerivedValue(() => { + progress.current = progressAnimVal.value; + }, [progressAnimVal]); + + return ; + }, + ); + + return Wrapper; + }; + + // Helper function to simulate swipe + const swipeToLeftOnce = () => { + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0 }, + { state: State.ACTIVE, translationX: -slideWidth * 0.25 }, + { state: State.ACTIVE, translationX: -slideWidth * 0.5 }, + { state: State.ACTIVE, translationX: -slideWidth * 0.75 }, + { state: State.END, translationX: -slideWidth }, + ]); + }; + + // Helper function to verify initial render + const verifyInitialRender = async ( + getByTestId: (testID: string | RegExp) => ReactTestInstance, + ) => { + await waitFor( + () => { + const item = getByTestId("carousel-item-0"); + expect(item).toBeTruthy(); + }, + { timeout: 1000 * 3 }, + ); + }; + + it("`data` prop: should render correctly", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + expect(getByTestId("carousel-item-0")).toBeTruthy(); + expect(getByTestId("carousel-item-1")).toBeTruthy(); + expect(getByTestId("carousel-item-2")).toBeTruthy(); + expect(getByTestId("carousel-item-3")).toBeTruthy(); + expect(getByTestId("carousel-item-4")).toBeTruthy(); + expect(getByTestId("carousel-item-5")).toBeTruthy(); + }); + + it("`renderItem` prop: should render items correctly", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render( + ( + {item} + )} + />, + ); + + await waitFor(() => expect(getByTestId("item-0")).toBeTruthy()); + }); + + it("should swipe to the left", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + // Test swipe sequence + for (let i = 1; i <= slideCount; i++) { + swipeToLeftOnce(); + await waitFor(() => expect(progress.current).toBe(i % slideCount)); + } + }); + + it("`loop` prop: should swipe back to the first item when loop is true", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + // Test swipe sequence + for (let i = 1; i <= slideCount; i++) { + swipeToLeftOnce(); + await waitFor(() => expect(progress.current).toBe(i % slideCount)); + } + }); + + it("`onSnapToItem` prop: should call the onSnapToItem callback", async () => { + const progress = { current: 0 }; + const onSnapToItem = jest.fn(); + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + expect(onSnapToItem).not.toHaveBeenCalled(); + + swipeToLeftOnce(); + await waitFor(() => expect(onSnapToItem).toHaveBeenCalledWith(1)); + + swipeToLeftOnce(); + await waitFor(() => expect(onSnapToItem).toHaveBeenCalledWith(2)); + + swipeToLeftOnce(); + await waitFor(() => expect(onSnapToItem).toHaveBeenCalledWith(3)); + }); + + it("`autoPlay` prop: should swipe automatically when autoPlay is true", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + await waitFor(() => expect(progress.current).toBe(1)); + await waitFor(() => expect(progress.current).toBe(2)); + await waitFor(() => expect(progress.current).toBe(3)); + await waitFor(() => expect(progress.current).toBe(0)); + }); + + it("`autoPlayReverse` prop: should swipe automatically in reverse when autoPlayReverse is true", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + await waitFor(() => expect(progress.current).toBe(3)); + await waitFor(() => expect(progress.current).toBe(2)); + await waitFor(() => expect(progress.current).toBe(1)); + await waitFor(() => expect(progress.current).toBe(0)); + }); + + it("`defaultIndex` prop: should render the correct item with the defaultIndex props", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + await waitFor(() => expect(progress.current).toBe(2)); + }); + + it("`defaultScrollOffsetValue` prop: should render the correct progress value with the defaultScrollOffsetValue props", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + const WrapperWithCustomProps = () => { + const defaultScrollOffsetValue = useSharedValue(-slideWidth); + + return ; + }; + + render(); + + await waitFor(() => expect(progress.current).toBe(1)); + }); + + it("`ref` prop: should handle the ref props", async () => { + const Wrapper = createWrapper({ current: 0 }); + const fn = jest.fn(); + const WrapperWithCustomProps: FC<{ + refSetupCallback: (ref: boolean) => void; + }> = ({ refSetupCallback }) => { + return ( + { + refSetupCallback(!!ref); + }} + /> + ); + }; + + render(); + + await waitFor(() => expect(fn).toHaveBeenCalledWith(true)); + }); + + it("`autoFillData` prop: should auto fill data array to allow loop playback when the loop props is true", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + { + const { getAllByTestId } = render( + , + ); + await waitFor(() => { + expect(getAllByTestId("carousel-item-0").length).toBe(3); + }); + } + + { + const { getAllByTestId } = render( + , + ); + await waitFor(() => { + expect(getAllByTestId("carousel-item-0").length).toBe(1); + }); + } + }); + + it("`pagingEnabled` prop: should swipe to the next item when pagingEnabled is true", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + { + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0 }, + { state: State.ACTIVE, translationX: -slideWidth * 0.15, velocityX: 0 }, + { state: State.END, translationX: -slideWidth * 0.25, velocityX: 0 }, + ]); + + await waitFor(() => expect(progress.current).toBe(0)); + } + + { + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0 }, + { state: State.ACTIVE, translationX: -slideWidth * 0.15, velocityX: 0 }, + { state: State.END, translationX: -slideWidth * 0.25, velocityX: 0 }, + ]); + + await waitFor(() => expect(progress.current).toBe(1)); + } + }); + + it("`onConfigurePanGesture` prop: should call the onConfigurePanGesture callback", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + let _pan: PanGesture | null = null; + render( + { + _pan = pan; + return pan; + }} + />, + ); + + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + expect(_pan).not.toBeNull(); + }); + + it("`onScrollStart` prop: should call the onScrollStart callback", async () => { + const progress = { current: 0 }; + let startedProgress: number | undefined; + const onScrollStart = () => { + if (typeof startedProgress === "number") return; + + startedProgress = progress.current; + }; + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0 }, + { state: State.ACTIVE, translationX: slideWidth / 2 }, + { state: State.END, translationX: slideWidth }, + ]); + + await waitFor(() => { + expect(startedProgress).toBe(0); + }); + }); + + it("`onScrollEnd` prop: should call the onScrollEnd callback", async () => { + const progress = { current: 0 }; + let endedProgress: number | undefined; + const onScrollEnd = jest.fn(() => { + if (typeof endedProgress === "number") return; + + endedProgress = progress.current; + }); + const Wrapper = createWrapper(progress); + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0 }, + { state: State.ACTIVE, translationX: slideWidth / 2 }, + { state: State.END, translationX: slideWidth }, + ]); + + await waitFor(() => { + expect(endedProgress).toBe(3); + expect(onScrollEnd).toHaveBeenCalledWith(3); + }); + }); + + it("`onProgressChange` prop: should call the onProgressChange callback", async () => { + const offsetProgressVal = { current: 0 }; + const absoluteProgressVal = { current: 0 }; + const onProgressChange = jest.fn((offsetProgress, absoluteProgress) => { + offsetProgressVal.current = offsetProgress; + absoluteProgressVal.current = absoluteProgress; + }); + const Wrapper = createWrapper(offsetProgressVal); + const { getByTestId } = render( + , + ); + await verifyInitialRender(getByTestId); + + await waitFor(() => { + expect(offsetProgressVal.current).toBe(0); + expect(absoluteProgressVal.current).toBe(0); + }); + + fireGestureHandler(getByGestureTestId(gestureTestId), [ + { state: State.BEGAN, translationX: 0 }, + { state: State.ACTIVE, translationX: -slideWidth / 2 }, + { state: State.END, translationX: -slideWidth }, + ]); + + await waitFor(() => { + expect(offsetProgressVal.current).toBe(-slideWidth); + expect(absoluteProgressVal.current).toBe(1); + }); + }); + + it("`fixedDirection` prop: should swipe to the correct direction when fixedDirection is positive", async () => { + const progress = { current: 0 }; + const Wrapper = createWrapper(progress); + { + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + swipeToLeftOnce(); + await waitFor(() => expect(progress.current).toBe(3)); + } + + { + const { getByTestId } = render(); + await verifyInitialRender(getByTestId); + + swipeToLeftOnce(); + await waitFor(() => expect(progress.current).toBe(1)); + } + }); +}); diff --git a/src/hooks/useAutoPlay.test.ts b/src/hooks/useAutoPlay.test.ts new file mode 100644 index 00000000..2238c80b --- /dev/null +++ b/src/hooks/useAutoPlay.test.ts @@ -0,0 +1,194 @@ +import { renderHook, act } from "@testing-library/react-hooks"; + +import { useAutoPlay } from "./useAutoPlay"; + +describe("useAutoPlay", () => { + // Mock timer + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + // Mock carousel controller + const mockCarouselController = { + prev: jest.fn(), + next: jest.fn(), + getCurrentIndex: jest.fn(), + getSharedIndex: jest.fn(), + scrollTo: jest.fn(), + }; + + it("should start autoplay when autoPlay is true", () => { + renderHook(() => + useAutoPlay({ + autoPlay: true, + autoPlayInterval: 1000, + carouselController: mockCarouselController, + }), + ); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockCarouselController.next).toHaveBeenCalledTimes(1); + expect(mockCarouselController.next).toHaveBeenCalledWith( + expect.objectContaining({ + onFinished: expect.any(Function), + }), + ); + }); + + it("should not start autoplay when autoPlay is false", () => { + renderHook(() => + useAutoPlay({ + autoPlay: false, + autoPlayInterval: 1000, + carouselController: mockCarouselController, + }), + ); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockCarouselController.next).not.toHaveBeenCalled(); + }); + + it("should play in reverse when autoPlayReverse is true", () => { + renderHook(() => + useAutoPlay({ + autoPlay: true, + autoPlayReverse: true, + autoPlayInterval: 1000, + carouselController: mockCarouselController, + }), + ); + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expect(mockCarouselController.prev).toHaveBeenCalledTimes(1); + expect(mockCarouselController.next).not.toHaveBeenCalled(); + }); + + it("should clear timer on unmount", () => { + const { unmount } = renderHook(() => + useAutoPlay({ + autoPlay: true, + autoPlayInterval: 1000, + carouselController: mockCarouselController, + }), + ); + + // Run the timer once to ensure it's set + act(() => { + jest.runOnlyPendingTimers(); + }); + + // Clear previous call records + mockCarouselController.next.mockClear(); + + // Then unmount the component and run the timer again + act(() => { + unmount(); + jest.runOnlyPendingTimers(); + }); + + expect(mockCarouselController.next).not.toHaveBeenCalled(); + }); + + it("should pause and resume autoplay", () => { + const { result } = renderHook(() => + useAutoPlay({ + autoPlay: true, + autoPlayInterval: 1000, + carouselController: mockCarouselController, + }), + ); + + // Run the timer once to ensure it's set + act(() => { + jest.runOnlyPendingTimers(); + }); + + // Clear previous call records + mockCarouselController.next.mockClear(); + + // Pause autoplay + act(() => { + result.current.pause(); + jest.runOnlyPendingTimers(); + }); + + expect(mockCarouselController.next).not.toHaveBeenCalled(); + + // Resume autoplay + act(() => { + result.current.start(); + jest.runOnlyPendingTimers(); + }); + + expect(mockCarouselController.next).toHaveBeenCalledTimes(1); + }); + + it("should respect autoPlayInterval timing", () => { + renderHook(() => + useAutoPlay({ + autoPlay: true, + autoPlayInterval: 2000, + carouselController: mockCarouselController, + }), + ); + + // Advance less than interval + act(() => { + jest.advanceTimersByTime(1500); + }); + expect(mockCarouselController.next).not.toHaveBeenCalled(); + + // Advance to complete interval + act(() => { + jest.advanceTimersByTime(500); + }); + expect(mockCarouselController.next).toHaveBeenCalledTimes(1); + }); + + it("should chain autoplay calls correctly", () => { + renderHook(() => + useAutoPlay({ + autoPlay: true, + autoPlayInterval: 1000, + carouselController: mockCarouselController, + }), + ); + + // First interval + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockCarouselController.next).toHaveBeenCalledTimes(1); + + // Trigger onFinished callback to start the next timer + const onFinished = mockCarouselController.next.mock.calls[0][0].onFinished; + act(() => { + onFinished(); + jest.runOnlyPendingTimers(); + }); + expect(mockCarouselController.next).toHaveBeenCalledTimes(2); + + // Trigger onFinished callback to start the next timer + const onFinished2 = mockCarouselController.next.mock.calls[1][0].onFinished; + act(() => { + onFinished2(); + jest.runOnlyPendingTimers(); + }); + expect(mockCarouselController.next).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/hooks/useCarouselController.test.tsx b/src/hooks/useCarouselController.test.tsx new file mode 100644 index 00000000..ebc283e2 --- /dev/null +++ b/src/hooks/useCarouselController.test.tsx @@ -0,0 +1,248 @@ +import { useSharedValue } from "react-native-reanimated"; + +import { renderHook, act } from "@testing-library/react-hooks"; + +import { useCarouselController } from "./useCarouselController"; + +// Mock Reanimated +jest.mock("react-native-reanimated", () => { + const mockRunOnJS = jest.fn((fn) => { + return (...args: any[]) => { + return fn(...args); + }; + }); + + const mockAnimatedReaction = jest.fn((deps, cb) => { + const depsResult = deps(); + cb(depsResult); + return () => {}; + }); + + return { + useSharedValue: jest.fn(initialValue => ({ + value: initialValue, + })), + useDerivedValue: jest.fn(callback => ({ + value: callback(), + })), + useAnimatedReaction: mockAnimatedReaction, + withTiming: jest.fn((toValue, config, callback) => { + if (callback) + callback(true); + + return toValue; + }), + runOnJS: mockRunOnJS, + mockAnimatedReaction, + mockRunOnJS, + Easing: { + bezier: () => ({ + factory: () => 0, + }), + }, + }; +}); + +// Get mock functions for testing +const { mockAnimatedReaction, mockRunOnJS } = jest.requireMock( + "react-native-reanimated", +); + +describe("useCarouselController", () => { + const mockHandlerOffset = useSharedValue(0); + const defaultProps = { + size: 300, + loop: true, + dataLength: 5, + handlerOffset: mockHandlerOffset, + autoFillData: false, + duration: 300, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockHandlerOffset.value = 0; + // Reset mock implementation + mockAnimatedReaction.mockImplementation((deps: () => any, cb: (depsResult: any) => void) => { + const depsResult = deps(); + cb(depsResult); + return () => {}; + }); + }); + + it("should initialize with default index", () => { + mockHandlerOffset.value = -600; // size * 2 + const { result } = renderHook(() => + useCarouselController({ + ...defaultProps, + defaultIndex: 2, + }), + ); + + expect(result.current.getCurrentIndex()).toBe(2); + }); + + it("should move to next slide", () => { + const { result } = renderHook(() => useCarouselController(defaultProps)); + + act(() => { + result.current.next(); + }); + + expect(mockHandlerOffset.value).toBe(-300); // size * 1 + }); + + it("should move to previous slide", () => { + const { result } = renderHook(() => useCarouselController(defaultProps)); + + act(() => { + result.current.prev(); + }); + + expect(mockHandlerOffset.value).toBe(300); // size * -1 + }); + + it("should handle loop behavior correctly", () => { + const { result } = renderHook(() => + useCarouselController({ + ...defaultProps, + loop: true, + }), + ); + + // Move to last slide + act(() => { + result.current.scrollTo({ index: 4 }); + }); + + // Try to go next (should loop to first) + act(() => { + result.current.next(); + }); + + expect(mockHandlerOffset.value).toBe(-1500); // size * 5 + }); + + it("should prevent movement when loop is disabled and at bounds", () => { + const { result } = renderHook(() => + useCarouselController({ + ...defaultProps, + loop: false, + }), + ); + + // Try to go previous at start + act(() => { + result.current.prev(); + }); + expect(mockHandlerOffset.value).toBe(0); + + // Go to end + act(() => { + result.current.scrollTo({ index: 4 }); + }); + + // Try to go next at end + act(() => { + result.current.next(); + }); + expect(mockHandlerOffset.value).toBe(-1200); // size * 4 + }); + + it("should scroll to specific index", () => { + const { result } = renderHook(() => useCarouselController(defaultProps)); + + act(() => { + result.current.scrollTo({ index: 3 }); + }); + + expect(mockHandlerOffset.value).toBe(-900); // size * 3 + }); + + it("should handle animation callbacks", () => { + const onFinished = jest.fn(); + const { result } = renderHook(() => useCarouselController(defaultProps)); + + act(() => { + result.current.next({ + animated: true, + onFinished, + }); + }); + + expect(onFinished).toHaveBeenCalled(); + }); + + it("should respect animation duration", () => { + const { result } = renderHook(() => + useCarouselController({ + ...defaultProps, + duration: 500, + }), + ); + + const onFinished = jest.fn(); + act(() => { + result.current.next({ + animated: true, + onFinished, + }); + }); + + expect(onFinished).toHaveBeenCalled(); + }); + + it("should handle non-animated transitions", () => { + const { result } = renderHook(() => useCarouselController(defaultProps)); + + act(() => { + result.current.scrollTo({ index: 2, animated: false }); + }); + + expect(mockHandlerOffset.value).toBe(-600); // size * 2 + }); + + it("should handle multiple slide movements", () => { + const { result } = renderHook(() => useCarouselController(defaultProps)); + + act(() => { + result.current.next({ count: 2 }); + }); + + expect(mockHandlerOffset.value).toBe(-600); // size * 2 + }); + + it("should maintain correct index with autoFillData", () => { + const { result } = renderHook(() => + useCarouselController({ + ...defaultProps, + autoFillData: true, + dataLength: 3, + }), + ); + + act(() => { + result.current.next(); + result.current.next(); + }); + + expect(result.current.getCurrentIndex()).toBe(2); + }); + + it("should handle animated reactions correctly", () => { + renderHook(() => useCarouselController(defaultProps)); + + expect(mockAnimatedReaction).toHaveBeenCalled(); + expect(mockRunOnJS).toHaveBeenCalled(); + }); + + it("should handle runOnJS correctly", () => { + const { result } = renderHook(() => useCarouselController(defaultProps)); + + act(() => { + result.current.next(); + }); + + expect(mockRunOnJS).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useCheckMounted.test.ts b/src/hooks/useCheckMounted.test.ts new file mode 100644 index 00000000..374d2023 --- /dev/null +++ b/src/hooks/useCheckMounted.test.ts @@ -0,0 +1,47 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { useCheckMounted } from "./useCheckMounted"; + +describe("useCheckMounted", () => { + it("should be mounted after initialization", () => { + const { result } = renderHook(() => useCheckMounted()); + + expect(result.current.current).toBe(true); + }); + + it("should be unmounted after cleanup", () => { + const { result, unmount } = renderHook(() => useCheckMounted()); + + expect(result.current.current).toBe(true); + + unmount(); + + expect(result.current.current).toBe(false); + }); + + it("should maintain mounted state during component lifecycle", () => { + const { result, rerender } = renderHook(() => useCheckMounted()); + + expect(result.current.current).toBe(true); + + rerender(); + + expect(result.current.current).toBe(true); + }); + + it("should handle multiple mount/unmount cycles", () => { + // First instance + const hook1 = renderHook(() => useCheckMounted()); + expect(hook1.result.current.current).toBe(true); + + hook1.unmount(); + expect(hook1.result.current.current).toBe(false); + + // Second instance + const hook2 = renderHook(() => useCheckMounted()); + expect(hook2.result.current.current).toBe(true); + + hook2.unmount(); + expect(hook2.result.current.current).toBe(false); + }); +}); diff --git a/src/hooks/useInitProps.test.tsx b/src/hooks/useInitProps.test.tsx new file mode 100644 index 00000000..d7893ca3 --- /dev/null +++ b/src/hooks/useInitProps.test.tsx @@ -0,0 +1,134 @@ +import React from "react"; +import { Text } from "react-native"; + +import { renderHook } from "@testing-library/react-hooks"; + +import { useInitProps } from "./useInitProps"; + +import type { TCarouselProps } from "../types"; + +describe("useInitProps", () => { + const defaultData = [1, 2, 3, 4]; + const defaultProps: TCarouselProps = { + data: defaultData, + width: 300, + height: 200, + renderItem: ({ item: _item }) => Item, + }; + + it("should initialize with default values", () => { + const { result } = renderHook(() => useInitProps(defaultProps)); + + expect(result.current).toEqual( + expect.objectContaining({ + defaultIndex: 0, + loop: true, + autoPlayInterval: 1000, + scrollAnimationDuration: 500, + width: 300, + height: 200, + enabled: true, + autoFillData: true, + pagingEnabled: true, + snapEnabled: true, + overscrollEnabled: true, + data: defaultData, + rawData: defaultData, + dataLength: 4, + rawDataLength: 4, + }), + ); + }); + + it("should handle custom values", () => { + const customProps: TCarouselProps = { + ...defaultProps, + defaultIndex: 2, + loop: false, + autoPlayInterval: 2000, + scrollAnimationDuration: 300, + enabled: false, + autoFillData: false, + pagingEnabled: false, + snapEnabled: false, + overscrollEnabled: false, + }; + + const { result } = renderHook(() => useInitProps(customProps)); + + expect(result.current).toEqual( + expect.objectContaining({ + ...customProps, + data: defaultData, + rawData: defaultData, + dataLength: 4, + rawDataLength: 4, + }), + ); + }); + it("should handle stack mode configuration", () => { + const stackProps: TCarouselProps = { + ...defaultProps, + mode: "horizontal-stack", + modeConfig: { + showLength: 3, + }, + }; + + const { result } = renderHook(() => useInitProps(stackProps)); + + expect(result.current.modeConfig).toBeDefined(); + if (result.current.modeConfig && "showLength" in result.current.modeConfig) + expect(result.current.modeConfig.showLength).toBe(3); // dataLength - 1 + }); + + it("should handle empty data array", () => { + const props: TCarouselProps = { + ...defaultProps, + data: [], + }; + + const { result } = renderHook(() => useInitProps(props)); + + expect(result.current.dataLength).toBe(0); + expect(result.current.rawDataLength).toBe(0); + }); + + it("should round width and height values", () => { + const props: TCarouselProps = { + ...defaultProps, + width: 300.6, + height: 200.4, + }; + + const { result } = renderHook(() => useInitProps(props)); + + expect(result.current.width).toBe(301); + expect(result.current.height).toBe(200); + }); + + it("should handle enableSnap property", () => { + const props: TCarouselProps = { + ...defaultProps, + enableSnap: false, + }; + + const { result } = renderHook(() => useInitProps(props)); + + expect(result.current.snapEnabled).toBe(false); + }); + it("should handle vertical-stack mode", () => { + const props: TCarouselProps = { + ...defaultProps, + mode: "vertical-stack", + modeConfig: { + showLength: 3, + }, + }; + + const { result } = renderHook(() => useInitProps(props)); + expect(result.current.modeConfig).toBeDefined(); + if (result.current.modeConfig && "showLength" in result.current.modeConfig) + expect(result.current.modeConfig.showLength).toBe(3); // dataLength - 1 + }); +}); diff --git a/src/hooks/useLayoutConfig.test.tsx b/src/hooks/useLayoutConfig.test.tsx new file mode 100644 index 00000000..37cbd9a6 --- /dev/null +++ b/src/hooks/useLayoutConfig.test.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import { View } from "react-native"; + +import { renderHook } from "@testing-library/react-hooks"; + +import { useLayoutConfig } from "./useLayoutConfig"; + +describe("useLayoutConfig", () => { + const defaultProps = { + size: 300, + vertical: false, + }; + it("should return normal layout by default", () => { + const { result } = renderHook(() => + useLayoutConfig({ + ...defaultProps, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + ); + + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe("function"); + }); + + it("should handle parallax mode", () => { + const props = { + ...defaultProps, + mode: "parallax" as const, + modeConfig: { + parallaxScrollingScale: 0.9, + parallaxScrollingOffset: 50, + parallaxAdjacentItemScale: 0.8, + }, + }; + + const { result } = renderHook(() => + useLayoutConfig({ + ...props, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + ); + const style = result.current(0); // Test with offset 0 + + expect(style.transform).toBeDefined(); + expect(style.transform).toContainEqual({ translateX: 0 }); + expect(style.transform).toContainEqual({ scale: 0.9 }); + }); + + it("should handle horizontal-stack mode", () => { + const props = { + ...defaultProps, + mode: "horizontal-stack" as const, + modeConfig: { + snapDirection: "left" as const, + showLength: 3, + }, + }; + + const { result } = renderHook(() => + useLayoutConfig({ + ...props, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + ); + const style = result.current(0); // Test with offset 0 + + expect(style.transform).toBeDefined(); + expect(style.zIndex).toBeDefined(); + expect(style.opacity).toBeDefined(); + }); + + it("should handle vertical-stack mode", () => { + const props = { + ...defaultProps, + mode: "vertical-stack" as const, + modeConfig: { + snapDirection: "left" as const, + showLength: 3, + }, + }; + + const { result } = renderHook(() => + useLayoutConfig({ + ...props, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + ); + const style = result.current(0); // Test with offset 0 + + expect(style.transform).toBeDefined(); + expect(style.zIndex).toBeDefined(); + expect(style.opacity).toBeDefined(); + }); + + it("should handle vertical orientation", () => { + const props = { + ...defaultProps, + vertical: true, + }; + + const { result } = renderHook(() => + useLayoutConfig({ + ...props, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + ); + const style = result.current(0); // Test with offset 0 + + expect(style.transform).toBeDefined(); + expect(style.transform).toContainEqual({ translateY: 0 }); + }); + + it("should handle different offsets", () => { + const { result } = renderHook(() => + useLayoutConfig({ + ...defaultProps, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + ); + + const style1 = result.current(-1); // Previous item + const style2 = result.current(0); // Current item + const style3 = result.current(1); // Next item + + expect(style1.transform).toContainEqual({ translateX: -300 }); + expect(style2.transform).toContainEqual({ translateX: 0 }); + expect(style3.transform).toContainEqual({ translateX: 300 }); + }); + + it("should memoize layout function", () => { + const { result, rerender } = renderHook(() => + useLayoutConfig({ + ...defaultProps, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + ); + const firstResult = result.current; + + rerender(); + expect(result.current).toBe(firstResult); + }); + + it("should update layout when props change", () => { + const { result, rerender } = renderHook( + props => + useLayoutConfig({ + ...props, + data: [], + renderItem: () => , + loop: false, + autoFillData: false, + defaultIndex: 0, + autoPlayInterval: 0, + scrollAnimationDuration: 0, + width: 0, + height: 0, + rawData: [], + dataLength: 0, + rawDataLength: 0, + }), + { + initialProps: defaultProps, + }, + ); + const firstResult = result.current; + + rerender({ ...defaultProps, size: 400 }); + expect(result.current).not.toBe(firstResult); + }); +}); diff --git a/src/hooks/useOnProgressChange.test.tsx b/src/hooks/useOnProgressChange.test.tsx new file mode 100644 index 00000000..8a134680 --- /dev/null +++ b/src/hooks/useOnProgressChange.test.tsx @@ -0,0 +1,173 @@ +import { useSharedValue } from "react-native-reanimated"; + +import { renderHook } from "@testing-library/react-hooks"; + +import { useOnProgressChange } from "./useOnProgressChange"; + +// Mock Reanimated and Easing +jest.mock("react-native-reanimated", () => { + let reactionCallback: ((value: any) => void) | null = null; + + return { + useSharedValue: jest.fn(initialValue => ({ + value: initialValue, + })), + useAnimatedReaction: jest.fn((deps, cb) => { + reactionCallback = cb; + const depsResult = deps(); + cb(depsResult); + return () => { + reactionCallback = null; + }; + }), + runOnJS: jest.fn(fn => fn), + Easing: { + bezier: () => ({ + factory: () => 0, + }), + }, + // Export the helper function for testing + __triggerReaction: (value: any) => { + if (reactionCallback) + reactionCallback(value); + }, + }; +}); + +// Mock computedOffsetXValueWithAutoFillData +jest.mock("../utils/computed-with-auto-fill-data", () => ({ + computedOffsetXValueWithAutoFillData: jest.fn(({ value }) => value), +})); + +describe("useOnProgressChange", () => { + const mockOffsetX = useSharedValue(0); + const mockOnProgressChange = jest.fn(); + const { __triggerReaction } = jest.requireMock("react-native-reanimated"); + + beforeEach(() => { + jest.clearAllMocks(); + mockOffsetX.value = 0; + }); + + it("should handle progress change with function callback", () => { + renderHook(() => + useOnProgressChange({ + size: 300, + autoFillData: false, + loop: false, + offsetX: mockOffsetX, + rawDataLength: 5, + onProgressChange: mockOnProgressChange, + }), + ); + + mockOffsetX.value = -300; // Move to next slide + __triggerReaction(mockOffsetX.value); + expect(mockOnProgressChange).toHaveBeenCalledWith(-300, 1); + }); + + it("should handle progress change with shared value", () => { + const progressValue = useSharedValue(0); + renderHook(() => + useOnProgressChange({ + size: 300, + autoFillData: false, + loop: false, + offsetX: mockOffsetX, + rawDataLength: 5, + onProgressChange: progressValue, + }), + ); + + mockOffsetX.value = -300; // Move to next slide + __triggerReaction(mockOffsetX.value); + expect(progressValue.value).toBe(1); + }); + + it("should handle loop mode", () => { + renderHook(() => + useOnProgressChange({ + size: 300, + autoFillData: false, + loop: true, + offsetX: mockOffsetX, + rawDataLength: 5, + onProgressChange: mockOnProgressChange, + }), + ); + + mockOffsetX.value = -1500; // Move to last slide + __triggerReaction(mockOffsetX.value); + expect(mockOnProgressChange).toHaveBeenCalledWith(-1500, 5); + }); + + it("should handle autoFillData mode", () => { + renderHook(() => + useOnProgressChange({ + size: 300, + autoFillData: true, + loop: false, + offsetX: mockOffsetX, + rawDataLength: 3, + onProgressChange: mockOnProgressChange, + }), + ); + + mockOffsetX.value = -300; // Move to next slide + __triggerReaction(mockOffsetX.value); + expect(mockOnProgressChange).toHaveBeenCalledWith(-300, 1); + }); + + it("should clamp values when not in loop mode", () => { + renderHook(() => + useOnProgressChange({ + size: 300, + autoFillData: false, + loop: false, + offsetX: mockOffsetX, + rawDataLength: 3, + onProgressChange: mockOnProgressChange, + }), + ); + + mockOffsetX.value = 300; // Try to move before first slide + __triggerReaction(mockOffsetX.value); + expect(mockOnProgressChange).toHaveBeenCalledWith(0, 0); + + mockOffsetX.value = -900; // Try to move after last slide + __triggerReaction(mockOffsetX.value); + expect(mockOnProgressChange).toHaveBeenCalledWith(-600, 2); + }); + + it("should handle positive offset values", () => { + renderHook(() => + useOnProgressChange({ + size: 300, + autoFillData: false, + loop: true, + offsetX: mockOffsetX, + rawDataLength: 5, + onProgressChange: mockOnProgressChange, + }), + ); + + mockOffsetX.value = 300; // Move backwards + __triggerReaction(mockOffsetX.value); + expect(mockOnProgressChange).toHaveBeenCalledWith(300, 4); + }); + + it("should not call onProgressChange if not provided", () => { + renderHook(() => + useOnProgressChange({ + size: 300, + autoFillData: false, + loop: false, + offsetX: mockOffsetX, + rawDataLength: 5, + }), + ); + + mockOffsetX.value = -300; + expect(mockOnProgressChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/usePanGestureProxy.ts b/src/hooks/usePanGestureProxy.ts index 72f4c895..a52fe80d 100644 --- a/src/hooks/usePanGestureProxy.ts +++ b/src/hooks/usePanGestureProxy.ts @@ -23,7 +23,7 @@ export const usePanGestureProxy = ( } = customization; const gesture = useMemo(() => { - const gesture = Gesture.Pan(); + const gesture = Gesture.Pan().withTestId("rnrc-gesture-handler"); // Save the original gesture callbacks const originalGestures = { diff --git a/src/hooks/useUpdateGestureConfig.test.ts b/src/hooks/useUpdateGestureConfig.test.ts new file mode 100644 index 00000000..0a4bbdf0 --- /dev/null +++ b/src/hooks/useUpdateGestureConfig.test.ts @@ -0,0 +1,95 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { useUpdateGestureConfig } from "./useUpdateGestureConfig"; + +describe("useUpdateGestureConfig", () => { + const mockGesture = { + enabled: jest.fn().mockReturnThis(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should update gesture enabled state", () => { + renderHook(() => + useUpdateGestureConfig(mockGesture as any, { + enabled: true, + }), + ); + + expect(mockGesture.enabled).toHaveBeenCalledWith(true); + }); + + it("should handle undefined enabled state", () => { + renderHook(() => useUpdateGestureConfig(mockGesture as any, {})); + + expect(mockGesture.enabled).not.toHaveBeenCalled(); + }); + + it("should update when enabled state changes", () => { + const { rerender } = renderHook( + props => useUpdateGestureConfig(mockGesture as any, props), + { + initialProps: { enabled: true }, + }, + ); + + expect(mockGesture.enabled).toHaveBeenCalledWith(true); + + rerender({ enabled: false }); + expect(mockGesture.enabled).toHaveBeenCalledWith(false); + }); + + it("should not update when enabled state remains the same", () => { + const { rerender } = renderHook( + props => useUpdateGestureConfig(mockGesture as any, props), + { + initialProps: { enabled: true }, + }, + ); + + mockGesture.enabled.mockClear(); + + rerender({ enabled: true }); + expect(mockGesture.enabled).not.toHaveBeenCalled(); + }); + + it("should handle gesture object changes", () => { + const newMockGesture = { + enabled: jest.fn().mockReturnThis(), + }; + + const { rerender } = renderHook( + ({ gesture, config }) => useUpdateGestureConfig(gesture as any, config), + { + initialProps: { + gesture: mockGesture, + config: { enabled: true }, + }, + }, + ); + + expect(mockGesture.enabled).toHaveBeenCalledWith(true); + + rerender({ + gesture: newMockGesture, + config: { enabled: true }, + }); + + expect(newMockGesture.enabled).toHaveBeenCalledWith(true); + }); + + it("should cleanup properly on unmount", () => { + const { unmount } = renderHook(() => + useUpdateGestureConfig(mockGesture as any, { + enabled: true, + }), + ); + + mockGesture.enabled.mockClear(); + unmount(); + + expect(mockGesture.enabled).not.toHaveBeenCalled(); + }); +}); diff --git a/src/types.ts b/src/types.ts index 2f31a228..a1736638 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,18 +60,24 @@ export interface WithTimingAnimation { export type WithAnimation = WithSpringAnimation | WithTimingAnimation; export type TCarouselProps = { + /** + * @test_coverage ✅ tested in Carousel.test.tsx > should handle the ref props + */ ref?: React.Ref; /** * The default animated value of the carousel. + * @test_coverage ✅ tested in Carousel.test.tsx > should render the correct progress value with the defaultScrollOffsetValue props */ defaultScrollOffsetValue?: SharedValue; /** * Carousel loop playback. * @default true + * @test_coverage ✅ tested in Carousel.test.tsx > should swipe back to the first item when loop is true */ loop?: boolean; /** * Carousel items data set. + * @test_coverage ✅ tested in Carousel.test.tsx > should render correctly */ data: T[]; /** @@ -80,20 +86,24 @@ export type TCarouselProps = { * @example * [1] => [1, 1, 1] * [1, 2] => [1, 2, 1, 2] + * @test_coverage ✅ tested in Carousel.test.tsx > should auto fill data array to allow loop playback when the loop props is true */ autoFillData?: boolean; /** * Default index * @default 0 + * @test_coverage ✅ tested in Carousel.test.tsx > should render the correct item with the defaultIndex props */ defaultIndex?: number; /** * Auto play + * @test_coverage ✅ tested in Carousel.test.tsx > should swipe automatically when autoPlay is true */ autoPlay?: boolean; /** * Auto play * @description reverse playback + * @test_coverage ✅ tested in Carousel.test.tsx > should swipe automatically in reverse when autoPlayReverse is true */ autoPlayReverse?: boolean; /** @@ -116,6 +126,7 @@ export type TCarouselProps = { containerStyle?: StyleProp; /** * PanGesture config + * @test_coverage ✅ tested in Carousel.test.tsx > should call the onConfigurePanGesture callback */ onConfigurePanGesture?: (panGesture: PanGesture) => void; /** @@ -128,6 +139,7 @@ export type TCarouselProps = { /** * When true, the scroll view stops on multiples of the scroll view's size when scrolling. * @default true + * @test_coverage ✅ tested in Carousel.test.tsx > should swipe to the next item when pagingEnabled is true */ pagingEnabled?: boolean; /** @@ -167,6 +179,7 @@ export type TCarouselProps = { /** * @experimental This API will be changed in the future. * If positive, the carousel will scroll to the positive direction and vice versa. + * @test_coverage ✅ tested in Carousel.test.tsx > should swipe to the correct direction when fixedDirection is positive * */ fixedDirection?: "positive" | "negative"; /** @@ -180,24 +193,29 @@ export type TCarouselProps = { customAnimation?: (value: number) => ViewStyle; /** * Render carousel item. + * @test_coverage ✅ tested in Carousel.test.tsx > should render items correctly */ renderItem: CarouselRenderItem; /** * Callback fired when navigating to an item. + * @test_coverage ✅ tested in Carousel.test.tsx > should call the onSnapToItem callback */ onSnapToItem?: (index: number) => void; /** * On scroll start + * @test_coverage ✅ tested in Carousel.test.tsx > should call the onScrollStart callback */ onScrollStart?: () => void; /** * On scroll end + * @test_coverage ✅ tested in Carousel.test.tsx > should call the onScrollEnd callback */ onScrollEnd?: (index: number) => void; /** * On progress change * @param offsetProgress Total of offset distance (0 390 780 ...) * @param absoluteProgress Convert to index (0 1 2 ...) + * @test_coverage ✅ tested in Carousel.test.tsx > should call the onProgressChange callback * * If you want to update a shared value automatically, you can use the shared value as a parameter directly. */ diff --git a/src/utils/computed-with-auto-fill-data.test.ts b/src/utils/computed-with-auto-fill-data.test.ts new file mode 100644 index 00000000..e7d9b44e --- /dev/null +++ b/src/utils/computed-with-auto-fill-data.test.ts @@ -0,0 +1,212 @@ +import { + computedFillDataWithAutoFillData, + computedOffsetXValueWithAutoFillData, + computedRealIndexWithAutoFillData, + convertToSharedIndex, +} from "./computed-with-auto-fill-data"; + +import { DATA_LENGTH } from "../constants"; + +const { SINGLE_ITEM, DOUBLE_ITEM } = DATA_LENGTH; + +describe("computed-with-auto-fill-data utilities", () => { + describe("computedFillDataWithAutoFillData", () => { + it("should handle single item", () => { + const data = [1]; + const result = computedFillDataWithAutoFillData({ + data, + loop: true, + autoFillData: true, + dataLength: SINGLE_ITEM, + }); + + expect(result).toEqual([1, 1, 1]); + }); + + it("should handle double items", () => { + const data = [1, 2]; + const result = computedFillDataWithAutoFillData({ + data, + loop: true, + autoFillData: true, + dataLength: DOUBLE_ITEM, + }); + + expect(result).toEqual([1, 2, 1, 2]); + }); + + it("should return original data when autoFillData is false", () => { + const data = [1, 2, 3]; + const result = computedFillDataWithAutoFillData({ + data, + loop: true, + autoFillData: false, + dataLength: 3, + }); + + expect(result).toEqual(data); + }); + + it("should return original data when loop is false", () => { + const data = [1, 2, 3]; + const result = computedFillDataWithAutoFillData({ + data, + loop: false, + autoFillData: true, + dataLength: 3, + }); + + expect(result).toEqual(data); + }); + }); + + describe("computedOffsetXValueWithAutoFillData", () => { + const size = 300; + + it("should handle single item", () => { + const result = computedOffsetXValueWithAutoFillData({ + value: size * 2, + size, + rawDataLength: SINGLE_ITEM, + loop: true, + autoFillData: true, + }); + + expect(result).toBe(0); // value % size + }); + + it("should handle double items", () => { + const result = computedOffsetXValueWithAutoFillData({ + value: size * 3, + size, + rawDataLength: DOUBLE_ITEM, + loop: true, + autoFillData: true, + }); + + expect(result).toBe(size * 1); // value % (size * 2) + }); + + it("should return original value when autoFillData is false", () => { + const value = size * 2; + const result = computedOffsetXValueWithAutoFillData({ + value, + size, + rawDataLength: 3, + loop: true, + autoFillData: false, + }); + + expect(result).toBe(value); + }); + + it("should return original value when loop is false", () => { + const value = size * 2; + const result = computedOffsetXValueWithAutoFillData({ + value, + size, + rawDataLength: 3, + loop: false, + autoFillData: true, + }); + + expect(result).toBe(value); + }); + }); + + describe("computedRealIndexWithAutoFillData", () => { + it("should handle single item", () => { + const result = computedRealIndexWithAutoFillData({ + index: 2, + dataLength: SINGLE_ITEM, + loop: true, + autoFillData: true, + }); + + expect(result).toBe(0); // index % 1 + }); + + it("should handle double items", () => { + const result = computedRealIndexWithAutoFillData({ + index: 3, + dataLength: DOUBLE_ITEM, + loop: true, + autoFillData: true, + }); + + expect(result).toBe(1); // index % 2 + }); + + it("should return original index when autoFillData is false", () => { + const index = 2; + const result = computedRealIndexWithAutoFillData({ + index, + dataLength: 3, + loop: true, + autoFillData: false, + }); + + expect(result).toBe(index); + }); + + it("should return original index when loop is false", () => { + const index = 2; + const result = computedRealIndexWithAutoFillData({ + index, + dataLength: 3, + loop: false, + autoFillData: true, + }); + + expect(result).toBe(index); + }); + }); + + describe("convertToSharedIndex", () => { + it("should handle single item", () => { + const result = convertToSharedIndex({ + index: 2, + rawDataLength: SINGLE_ITEM, + loop: true, + autoFillData: true, + }); + + expect(result).toBe(0); + }); + + it("should handle double items", () => { + const result = convertToSharedIndex({ + index: 3, + rawDataLength: DOUBLE_ITEM, + loop: true, + autoFillData: true, + }); + + expect(result).toBe(1); // index % 2 + }); + + it("should return original index when autoFillData is false", () => { + const index = 2; + const result = convertToSharedIndex({ + index, + rawDataLength: 3, + loop: true, + autoFillData: false, + }); + + expect(result).toBe(index); + }); + + it("should return original index when loop is false", () => { + const index = 2; + const result = convertToSharedIndex({ + index, + rawDataLength: 3, + loop: false, + autoFillData: true, + }); + + expect(result).toBe(index); + }); + }); +}); diff --git a/src/utils/deal-with-animation.test.ts b/src/utils/deal-with-animation.test.ts new file mode 100644 index 00000000..1f9489f4 --- /dev/null +++ b/src/utils/deal-with-animation.test.ts @@ -0,0 +1,99 @@ +import { withSpring, withTiming } from "react-native-reanimated"; + +import { dealWithAnimation } from "./deal-with-animation"; + +// Mock Reanimated +jest.mock("react-native-reanimated", () => ({ + withSpring: jest.fn((value, _config, callback) => { + callback?.(true); + return value; + }), + withTiming: jest.fn((value, _config, callback) => { + callback?.(true); + return value; + }), +})); + +describe("dealWithAnimation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should handle spring animation", () => { + const callback = jest.fn(); + const springConfig = { + damping: 20, + stiffness: 90, + }; + + const animation = dealWithAnimation({ + type: "spring", + config: springConfig, + }); + + const result = animation(100, callback); + + expect(withSpring).toHaveBeenCalledWith( + 100, + springConfig, + expect.any(Function), + ); + expect(callback).toHaveBeenCalledWith(true); + expect(result).toBe(100); + }); + + it("should handle timing animation", () => { + const callback = jest.fn(); + const timingConfig = { + duration: 300, + }; + + const animation = dealWithAnimation({ + type: "timing", + config: timingConfig, + }); + + const result = animation(100, callback); + + expect(withTiming).toHaveBeenCalledWith( + 100, + timingConfig, + expect.any(Function), + ); + expect(callback).toHaveBeenCalledWith(true); + expect(result).toBe(100); + }); + + it("should pass animation config correctly", () => { + const springConfig = { + damping: 10, + mass: 1, + stiffness: 100, + }; + + const animation = dealWithAnimation({ + type: "spring", + config: springConfig, + }); + + animation(100, jest.fn()); + + expect(withSpring).toHaveBeenCalledWith( + 100, + expect.objectContaining(springConfig), + expect.any(Function), + ); + }); + + it("should handle animation completion", () => { + const callback = jest.fn(); + const animation = dealWithAnimation({ + type: "timing", + config: { duration: 300 }, + }); + + animation(100, callback); + + expect(callback).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/utils/log.test.ts b/src/utils/log.test.ts new file mode 100644 index 00000000..b029853a --- /dev/null +++ b/src/utils/log.test.ts @@ -0,0 +1,67 @@ +import { log, round } from "./log"; + +describe("log utilities", () => { + describe("log", () => { + const mockConsoleLog = jest.fn(); + // eslint-disable-next-line no-console + const originalConsoleLog = console.log; + + beforeEach(() => { + // eslint-disable-next-line no-console + console.log = mockConsoleLog; + }); + + afterEach(() => { + mockConsoleLog.mockClear(); + // eslint-disable-next-line no-console + console.log = originalConsoleLog; + }); + + it("should call console.log with provided arguments", () => { + const args = ["test", 123, { key: "value" }]; + log(...args); + + expect(mockConsoleLog).toHaveBeenCalledWith(...args); + }); + + it("should handle single argument", () => { + log("test"); + expect(mockConsoleLog).toHaveBeenCalledWith("test"); + }); + + it("should handle multiple arguments", () => { + log("test", 123, true); + expect(mockConsoleLog).toHaveBeenCalledWith("test", 123, true); + }); + }); + + describe("round", () => { + it("should round positive numbers correctly", () => { + expect(round(1.4)).toBe(1); + expect(round(1.5)).toBe(2); + expect(round(1.6)).toBe(2); + }); + + it("should round negative numbers correctly", () => { + expect(round(-1.4)).toBe(-1); + expect(round(-1.5)).toBe(-1); + expect(round(-1.6)).toBe(-2); + }); + + it("should handle zero values", () => { + expect(round(0)).toBe(0); + expect(round(-0)).toBe(-0); + expect(1 / round(-0)).toBe(-Infinity); + }); + + it("should handle integers", () => { + expect(round(5)).toBe(5); + expect(round(-5)).toBe(-5); + }); + + it("should handle decimal places", () => { + expect(round(3.14159)).toBe(3); + expect(round(-3.14159)).toBe(-3); + }); + }); +}); diff --git a/jest-setup.js b/test/jest-setup.js similarity index 100% rename from jest-setup.js rename to test/jest-setup.js diff --git a/test/reporter.js b/test/reporter.js new file mode 100644 index 00000000..374b60da --- /dev/null +++ b/test/reporter.js @@ -0,0 +1,21 @@ +/* eslint-disable prefer-rest-params */ +const { DefaultReporter } = require("@jest/reporters"); + +class Reporter extends DefaultReporter { + constructor() { + super(...arguments); + } + + printTestFileHeader(_testPath, config, result) { + const console = result.console; + + if (result.numFailingTests === 0 && !result.testExecError) + result.console = null; + + super.printTestFileHeader(...arguments); + + result.console = console; + } +} + +module.exports = Reporter;