// @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(); }); });