// @ts-nocheck
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { render, screen, act, fireEvent } from '@testing-library/react';
// ─── Mocks ───────────────────────────────────────────────────────────────
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn() }),
}));
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, exit, className, ...props }: any) => (
{children}
),
},
AnimatePresence: ({ children }: any) => <>{children}>,
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => ;
Icon.displayName = name;
return Icon;
};
return {
ArrowLeft: mockIcon('arrow-left'),
ArrowRight: mockIcon('arrow-right'),
};
});
// ─── SessionStorage Mock ─────────────────────────────────────────────────
const mockSessionStorage: Record = {};
// Mock navigator.vibrate for haptic feedback tests
const mockVibrate = jest.fn();
Object.defineProperty(navigator, 'vibrate', {
value: mockVibrate,
configurable: true,
writable: true,
});
beforeEach(() => {
mockSessionStorage['swipe-hint-shown'] = 'true'; // prevent onboarding hint by default
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(
(key: string) => mockSessionStorage[key] ?? null
);
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(
(key: string, value: string) => { mockSessionStorage[key] = value; }
);
jest.useFakeTimers();
mockVibrate.mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
// ─── Tests: SwipeNavigation ──────────────────────────────────────────────
describe('SwipeNavigation', () => {
it('renders prev and next labels when both routes provided', async () => {
// Clear swipe-hint-shown so the onboarding hint triggers
mockSessionStorage['swipe-hint-shown'] = '';
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
// Advance timers past the 1500ms onboarding hint delay
act(() => {
jest.advanceTimersByTime(1500);
});
expect(screen.getByText('上一页')).toBeInTheDocument();
expect(screen.getByText('下一页')).toBeInTheDocument();
expect(screen.getByTestId('icon-arrow-left')).toBeInTheDocument();
expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument();
});
it('renders only prev label when only prevRoute provided', async () => {
mockSessionStorage['swipe-hint-shown'] = '';
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
act(() => {
jest.advanceTimersByTime(1500);
});
expect(screen.getByText('上一页')).toBeInTheDocument();
expect(screen.queryByText('下一页')).not.toBeInTheDocument();
expect(screen.getByTestId('icon-arrow-left')).toBeInTheDocument();
expect(screen.queryByTestId('icon-arrow-right')).not.toBeInTheDocument();
});
it('renders only next label when only nextRoute provided', async () => {
mockSessionStorage['swipe-hint-shown'] = '';
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
act(() => {
jest.advanceTimersByTime(1500);
});
expect(screen.getByText('下一页')).toBeInTheDocument();
expect(screen.queryByText('上一页')).not.toBeInTheDocument();
expect(screen.getByTestId('icon-arrow-right')).toBeInTheDocument();
expect(screen.queryByTestId('icon-arrow-left')).not.toBeInTheDocument();
});
it('renders default labels when no labels provided', async () => {
mockSessionStorage['swipe-hint-shown'] = '';
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
act(() => {
jest.advanceTimersByTime(1500);
});
expect(screen.getByText('上一页')).toBeInTheDocument();
expect(screen.getByText('下一页')).toBeInTheDocument();
});
it('shows swipe dots indicator when routes exist', async () => {
const { SwipeNavigation } = await import('./use-swipe-gesture');
const { container } = render(
);
// The swipe dots indicator renders as a fixed-position div
const dotsContainer = container.querySelector('.fixed');
expect(dotsContainer).toBeInTheDocument();
});
it('shows swipe dots when only prevRoute provided', async () => {
const { SwipeNavigation } = await import('./use-swipe-gesture');
const { container } = render(
);
// Should still show dots indicator with only one route
const dotsContainer = container.querySelector('.fixed');
expect(dotsContainer).toBeInTheDocument();
});
it('shows swipe dots when only nextRoute provided', async () => {
const { SwipeNavigation } = await import('./use-swipe-gesture');
const { container } = render(
);
const dotsContainer = container.querySelector('.fixed');
expect(dotsContainer).toBeInTheDocument();
});
it('does not show swipe dots when no routes provided', async () => {
const { SwipeNavigation } = await import('./use-swipe-gesture');
const { container } = render();
expect(container.querySelector('.fixed')).toBeNull();
});
it('applies custom className', async () => {
const { SwipeNavigation } = await import('./use-swipe-gesture');
const { container } = render(
);
const div = container.querySelector('.custom-class');
expect(div).toBeInTheDocument();
});
it('does not show onboarding hint when already visited', async () => {
// swipe-hint-shown is already 'true' from beforeEach
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
// Advance timers - should NOT show hint since already visited
act(() => {
jest.advanceTimersByTime(1500);
});
expect(screen.queryByText('提示')).not.toBeInTheDocument();
});
it('shows swipe hint (prev direction) when only prevRoute exists', async () => {
mockSessionStorage['swipe-hint-shown'] = '';
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
act(() => {
jest.advanceTimersByTime(1500);
});
// The hint text should show the prev label
expect(screen.getByText('上一页')).toBeInTheDocument();
});
it('shows swipe hint (next direction) when only nextRoute exists', async () => {
mockSessionStorage['swipe-hint-shown'] = '';
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
act(() => {
jest.advanceTimersByTime(1500);
});
expect(screen.getByText('下一页')).toBeInTheDocument();
});
it('auto-dismisses onboarding hint after 3 seconds', async () => {
mockSessionStorage['swipe-hint-shown'] = '';
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
// Show hint
act(() => {
jest.advanceTimersByTime(1500);
});
expect(screen.getByText('提示')).toBeInTheDocument();
// Wait for auto-dismiss
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.queryByText('提示')).not.toBeInTheDocument();
});
it('sets sessionStorage on mount when not visited', async () => {
mockSessionStorage['swipe-hint-shown'] = '';
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem');
const { SwipeNavigation } = await import('./use-swipe-gesture');
render(
);
expect(setItemSpy).toHaveBeenCalledWith('swipe-hint-shown', 'true');
setItemSpy.mockRestore();
});
});
// ─── Tests: PullToRefresh ────────────────────────────────────────────────
describe('PullToRefresh', () => {
let originalScrollY: PropertyDescriptor | undefined;
beforeEach(() => {
// Mock scrollY = 0 (at top of page) by default
originalScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY');
Object.defineProperty(window, 'scrollY', {
value: 0,
configurable: true,
writable: true,
});
});
afterEach(() => {
if (originalScrollY) {
Object.defineProperty(window, 'scrollY', originalScrollY);
}
});
it('renders children content', async () => {
const { PullToRefresh } = await import('./use-swipe-gesture');
render(
Promise}>
Child Content
);
expect(screen.getByTestId('child-content')).toBeInTheDocument();
expect(screen.getByText('Child Content')).toBeInTheDocument();
});
it('applies custom className', async () => {
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
Promise} className="custom-class">
content
);
const outer = container.querySelector('.custom-class');
expect(outer).toBeInTheDocument();
});
it('has touch event handlers on container', async () => {
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
Promise}>
content
);
const outer = container.firstChild as HTMLElement;
expect(outer).toBeInTheDocument();
expect(outer.tagName).toBe('DIV');
// The outer div is rendered with className="relative" (from cn('relative', className))
expect(container.querySelector('.relative')).toBeInTheDocument();
});
it('starts pulling when touch starts at scrollY=0', async () => {
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
// Simulate touch start at top of page
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
// Touch move downward
fireEvent.touchMove(outer, {
touches: [{ clientY: 150, clientX: 0, identifier: 0 }],
});
// Touch end - pull distance > 60 should trigger refresh
fireEvent.touchEnd(outer);
expect(onRefresh).toHaveBeenCalled();
});
it('does not pull when scrollY > 0', async () => {
Object.defineProperty(window, 'scrollY', {
value: 100,
configurable: true,
writable: true,
});
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
fireEvent.touchMove(outer, {
touches: [{ clientY: 150, clientX: 0, identifier: 0 }],
});
fireEvent.touchEnd(outer);
expect(onRefresh).not.toHaveBeenCalled();
});
it('does not refresh when pull distance is below threshold', async () => {
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
// Small pull (distance = 50 * 0.5 = 25 < 60)
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
fireEvent.touchMove(outer, {
touches: [{ clientY: 50, clientX: 0, identifier: 0 }],
});
fireEvent.touchEnd(outer);
expect(onRefresh).not.toHaveBeenCalled();
});
it('handles touch move without touches after pull started', async () => {
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
// Touch move without touches should not throw
expect(() => {
fireEvent.touchMove(outer, {});
}).not.toThrow();
});
it('caps pull distance at 100', async () => {
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
// Very large pull (distance = 300 * 0.5 = 150, capped at 100)
fireEvent.touchMove(outer, {
touches: [{ clientY: 300, clientX: 0, identifier: 0 }],
});
// Should still trigger refresh since pull > 60
fireEvent.touchEnd(outer);
expect(onRefresh).toHaveBeenCalled();
});
it('resets pullDistance after touch end', async () => {
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
fireEvent.touchMove(outer, {
touches: [{ clientY: 50, clientX: 0, identifier: 0 }],
});
fireEvent.touchEnd(outer);
// After touch end, pullDistance resets to 0, onRefresh not called
expect(onRefresh).not.toHaveBeenCalled();
});
it('does not pull when starting with negative Y (scroll up)', async () => {
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
// Touch start at Y=0, but touch move goes up (negative)
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
// Moving up should not trigger refresh
fireEvent.touchMove(outer, {
touches: [{ clientY: -50, clientX: 0, identifier: 0 }],
});
fireEvent.touchEnd(outer);
expect(onRefresh).not.toHaveBeenCalled();
});
it('triggers medium haptic feedback on successful pull refresh', async () => {
const onRefresh = jest.fn() as unknown as () => Promise;
const { PullToRefresh } = await import('./use-swipe-gesture');
const { container } = render(
content
);
const outer = container.firstChild as HTMLElement;
// Pull past threshold (distance = 150 * 0.5 = 75 > 60)
fireEvent.touchStart(outer, {
touches: [{ clientY: 0, clientX: 0, identifier: 0 }],
});
fireEvent.touchMove(outer, {
touches: [{ clientY: 150, clientX: 0, identifier: 0 }],
});
fireEvent.touchEnd(outer);
// Should trigger medium haptic (25ms vibration)
expect(mockVibrate).toHaveBeenCalledWith(25);
expect(onRefresh).toHaveBeenCalled();
});
});