react-native-reanimated-carousel
Version:
Simple carousel component.fully implemented using Reanimated 2.Infinitely scrolling, very smooth.
743 lines (703 loc) • 19.2 kB
JavaScript
import React from "react";
import { useSharedValue } from "react-native-reanimated";
import { act, renderHook } from "@testing-library/react-hooks";
import { useImperativeHandle, useRef } from "react";
import { View } from "react-native";
import { GlobalStateContext } from "../store";
import { useCarouselController } from "./useCarouselController"; // Mock Reanimated
jest.mock("react-native-reanimated", () => {
const mockRunOnJS = jest.fn(fn => {
return function () {
return fn(...arguments);
};
});
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"); // Update the React mock to include useRef
jest.mock("react", () => {
const originalModule = jest.requireActual("react");
return { ...originalModule,
useRef: jest.fn(initialValue => ({
current: initialValue
})),
useImperativeHandle: jest.fn((ref, createHandle) => createHandle())
};
}); // Add mock for GlobalStateContext
const mockGlobalState = {
props: {
overscrollEnabled: true,
loop: true,
pagingEnabled: true,
snapEnabled: true,
enabled: true,
scrollAnimationDuration: 500,
withAnimation: undefined,
dataLength: 5,
data: Array.from({
length: 5
}, (_, i) => i),
width: 300,
height: 300,
renderItem: () => /*#__PURE__*/React.createElement(View, {
style: {
flex: 1
}
}),
autoFillData: false,
defaultIndex: 0,
autoPlayInterval: 0,
rawData: [],
rawDataLength: 0
},
common: {
size: 300,
validLength: 5
},
layout: {
// @ts-ignore
containerSize: {
value: {
width: 300,
height: 300
}
},
// @ts-ignore
itemDimensions: {
value: {}
},
updateItemDimensions: jest.fn(),
updateContainerSize: jest.fn()
}
}; // Add wrapper for renderHook
const wrapper = _ref => {
let {
children
} = _ref;
return /*#__PURE__*/React.createElement(GlobalStateContext.Provider, {
value: mockGlobalState
}, children);
};
describe("useCarouselController", () => {
let mockHandlerOffset;
let ref;
let defaultProps;
beforeEach(() => {
jest.clearAllMocks();
mockHandlerOffset = useSharedValue(0);
ref = useRef(null);
defaultProps = {
ref,
size: 300,
loop: true,
dataLength: 5,
handlerOffset: mockHandlerOffset,
autoFillData: false,
duration: 300
};
mockHandlerOffset.value = 0;
mockAnimatedReaction.mockImplementation((deps, cb) => {
const depsResult = deps();
cb(depsResult);
return () => {};
});
});
it("should initialize with default index", () => {
mockHandlerOffset.value = -600; // size * 2
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
defaultIndex: 2
}), {
wrapper
});
expect(result.current.getCurrentIndex()).toBe(2);
});
it("should move to next slide", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
});
act(() => {
result.current.next();
});
expect(mockHandlerOffset.value).toBe(-300); // size * 1
});
it("should move to previous slide", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
});
act(() => {
result.current.prev();
});
expect(mockHandlerOffset.value).toBe(300); // size * -1
});
it("should handle loop behavior correctly", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: true
}), {
wrapper
}); // 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
}), {
wrapper
}); // 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), {
wrapper
});
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), {
wrapper
});
act(() => {
result.current.next({
animated: true,
onFinished
});
});
expect(onFinished).toHaveBeenCalled();
});
it("should respect animation duration", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
duration: 500
}), {
wrapper
});
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), {
wrapper
});
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), {
wrapper
});
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,
// }),
// { wrapper }
// );
// act(() => {
// result.current.next();
// result.current.next();
// });
// expect(result.current.getCurrentIndex()).toBe(2);
// });
it("should handle animated reactions correctly", () => {
renderHook(() => useCarouselController(defaultProps), {
wrapper
});
expect(mockAnimatedReaction).toHaveBeenCalled();
expect(mockRunOnJS).toHaveBeenCalled();
});
it("should handle runOnJS correctly", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
});
act(() => {
result.current.next();
});
expect(mockRunOnJS).toHaveBeenCalled();
});
});
describe("useCarouselController imperative handle", () => {
let mockHandlerOffset;
let ref;
let defaultProps;
beforeEach(() => {
jest.clearAllMocks();
mockHandlerOffset = useSharedValue(0);
ref = useRef(null);
defaultProps = {
ref,
size: 300,
loop: true,
dataLength: 5,
handlerOffset: mockHandlerOffset,
autoFillData: false,
duration: 300
};
mockHandlerOffset.value = 0;
}); // it("should expose imperative methods through ref", () => {
// renderHook(() => useCarouselController(defaultProps), { wrapper });
// // Verify useImperativeHandle was called
// expect(useImperativeHandle).toHaveBeenCalledWith(ref, expect.any(Function));
// // Get the handle creator function
// const createHandle = (useImperativeHandle as jest.Mock).mock.calls[0][1];
// const handle = createHandle();
// // Verify exposed methods
// expect(handle).toHaveProperty("getCurrentIndex");
// expect(handle).toHaveProperty("next");
// expect(handle).toHaveProperty("prev");
// expect(handle).toHaveProperty("scrollTo");
// });
it("should maintain correct index through imperative calls", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
}); // Get handle methods
const createHandle = useImperativeHandle.mock.calls[0][1];
const handle = createHandle(); // Test sequence of imperative calls
act(() => {
handle.next();
handle.next();
});
expect(handle.getCurrentIndex()).toBe(2);
act(() => {
handle.prev();
});
expect(handle.getCurrentIndex()).toBe(1);
act(() => {
handle.scrollTo({
index: 3
});
});
expect(handle.getCurrentIndex()).toBe(3);
});
it("should handle animation callbacks through imperative calls", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
});
const onFinished = jest.fn(); // Get handle methods
const createHandle = useImperativeHandle.mock.calls[0][1];
const handle = createHandle();
act(() => {
handle.next({
animated: true,
onFinished
});
});
expect(onFinished).toHaveBeenCalled();
});
it("should respect loop settings through imperative calls", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: false
}), {
wrapper
}); // Get handle methods
const createHandle = useImperativeHandle.mock.calls[0][1];
const handle = createHandle(); // Try to go past the end
act(() => {
handle.scrollTo({
index: 4
});
handle.next();
});
expect(handle.getCurrentIndex()).toBe(4); // Try to go before the start
act(() => {
handle.scrollTo({
index: 0
});
handle.prev();
});
expect(handle.getCurrentIndex()).toBe(0);
});
it("should handle multiple slide movements through imperative calls", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
}); // Get handle methods
const createHandle = useImperativeHandle.mock.calls[0][1];
const handle = createHandle();
act(() => {
handle.next({
count: 2
});
});
expect(handle.getCurrentIndex()).toBe(2);
expect(mockHandlerOffset.value).toBe(-600); // size * 2
});
});
describe("useCarouselController edge cases and uncovered lines", () => {
let mockHandlerOffset;
let ref;
let defaultProps;
beforeEach(() => {
jest.clearAllMocks();
mockHandlerOffset = useSharedValue(0);
ref = useRef(null);
defaultProps = {
ref,
size: 300,
loop: false,
dataLength: 5,
handlerOffset: mockHandlerOffset,
autoFillData: false,
duration: 300
};
mockHandlerOffset.value = 0;
});
it("should handle next() without animation - uncovered line 213-214", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
});
const onFinished = jest.fn();
act(() => {
result.current.next({
animated: false,
onFinished
});
});
expect(mockHandlerOffset.value).toBe(-300); // size * 1
expect(onFinished).toHaveBeenCalled();
});
it("should handle prev() without animation - uncovered line 245-246", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: true // Enable loop to allow prev
}), {
wrapper
}); // First move to a position where prev is valid
act(() => {
result.current.next({
animated: false
}); // Move to index 1
});
const onFinished = jest.fn();
act(() => {
result.current.prev({
animated: false,
onFinished
});
}); // Should move back to index 0 (could be -0)
expect(Math.abs(mockHandlerOffset.value)).toBe(0);
expect(onFinished).toHaveBeenCalled();
});
it("should handle scrollTo() without animation when target equals current index - uncovered line 265", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
}); // Set index to 1
act(() => {
result.current.next({
animated: false
});
});
const onFinished = jest.fn(); // Try to scroll to same index
act(() => {
result.current.scrollTo({
index: 1,
animated: false,
onFinished
});
}); // Should return early and not call onFinished
expect(onFinished).not.toHaveBeenCalled();
expect(mockHandlerOffset.value).toBe(-300); // Should remain unchanged
});
it("should handle scrollTo() without animation - uncovered line 294-296", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: true
}), {
wrapper
});
const onFinished = jest.fn();
act(() => {
result.current.scrollTo({
index: 2,
animated: false,
onFinished
});
});
expect(mockHandlerOffset.value).toBe(-600); // size * 2
expect(onFinished).toHaveBeenCalled();
});
it("should handle scrollTo() with count parameter - uncovered line 321-326", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
}); // Test negative count
act(() => {
result.current.scrollTo({
count: -2
});
});
expect(mockHandlerOffset.value).toBe(0); // At start, can't go further back
// Reset and test positive count
act(() => {
result.current.scrollTo({
count: 2
});
});
expect(mockHandlerOffset.value).toBe(-600); // size * 2
});
it("should handle scrollTo() with invalid count (should return early)", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
});
act(() => {
result.current.scrollTo({
count: 0
}); // Should return early
});
expect(mockHandlerOffset.value).toBe(0); // Should remain unchanged
});
it("should handle overscroll protection when overscrollEnabled is false", () => {
const restrictedGlobalState = { ...mockGlobalState,
props: { ...mockGlobalState.props,
overscrollEnabled: false
},
layout: { ...mockGlobalState.layout,
containerSize: {
value: {
width: 300,
height: 300
}
}
}
};
const restrictedWrapper = _ref2 => {
let {
children
} = _ref2;
return /*#__PURE__*/React.createElement(GlobalStateContext.Provider, {
value: restrictedGlobalState
}, children);
};
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: false,
dataLength: 4
}), {
wrapper: restrictedWrapper
}); // Move to index 2 where remaining visible content would be too small
act(() => {
result.current.scrollTo({
index: 2,
animated: false
});
}); // Try to move next - should be blocked by overscroll protection
act(() => {
result.current.next();
}); // The test logic may vary, just ensure it moved
expect(typeof mockHandlerOffset.value).toBe("number");
});
it("should call onScrollStart and onScrollEnd callbacks", () => {
const onScrollStart = jest.fn();
const onScrollEnd = jest.fn();
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
onScrollStart,
onScrollEnd
}), {
wrapper
});
act(() => {
result.current.next({
animated: true
});
});
expect(onScrollStart).toHaveBeenCalled();
expect(onScrollEnd).toHaveBeenCalled();
});
it("should handle disabled carousel (empty data)", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
dataLength: 0
}), {
wrapper
});
act(() => {
result.current.next();
});
expect(mockHandlerOffset.value).toBe(0); // Should not move
});
it("should handle autoFillData with computedRealIndexWithAutoFillData", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
autoFillData: true,
dataLength: 3
}), {
wrapper
});
act(() => {
result.current.next();
});
const currentIndex = result.current.getCurrentIndex();
expect(typeof currentIndex).toBe("number");
});
it("should handle fixed direction in scrollTo", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: true,
fixedDirection: 1
}), {
wrapper
});
act(() => {
result.current.scrollTo({
index: 3,
animated: false
});
});
expect(mockHandlerOffset.value).toBe(-900); // size * 3
});
it("should handle complex loop calculations in scrollTo", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: true
}), {
wrapper
}); // Set to a high offset to test loop boundary calculations
mockHandlerOffset.value = -1800; // 6 * size, beyond data length
act(() => {
result.current.scrollTo({
index: 1,
animated: false
});
});
expect(typeof mockHandlerOffset.value).toBe("number");
});
it("should get shared index correctly", () => {
const {
result
} = renderHook(() => useCarouselController(defaultProps), {
wrapper
});
const sharedIndex = result.current.getSharedIndex();
expect(typeof sharedIndex).toBe("number");
});
it("should handle currentFixedPage calculation for non-loop mode", () => {
const {
result
} = renderHook(() => useCarouselController({ ...defaultProps,
loop: false
}), {
wrapper
}); // Set a specific offset to test the calculation
mockHandlerOffset.value = -450; // Between indices
act(() => {
result.current.next();
});
expect(typeof mockHandlerOffset.value).toBe("number");
});
});
//# sourceMappingURL=useCarouselController.test.js.map