diff --git a/site/test-coverage.js b/site/test-coverage.js index 8886ceacf..170d3c433 100644 --- a/site/test-coverage.js +++ b/site/test-coverage.js @@ -55,7 +55,7 @@ module.exports = { steps: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, sticky: { statements: '67.85%', branches: '30%', functions: '85.71%', lines: '69.09%' }, swipeCell: { statements: '4.42%', branches: '0%', functions: '0%', lines: '4.67%' }, - swiper: { statements: '3.77%', branches: '0.9%', functions: '1.4%', lines: '3.89%' }, + swiper: { statements: '97.68%', branches: '94.73%', functions: '100%', lines: '99.67%' }, switch: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, tabBar: { statements: '10%', branches: '0%', functions: '0%', lines: '10.81%' }, table: { statements: '100%', branches: '90%', functions: '100%', lines: '100%' }, diff --git a/src/swiper/Swiper.tsx b/src/swiper/Swiper.tsx index 14f40180e..749c0a794 100644 --- a/src/swiper/Swiper.tsx +++ b/src/swiper/Swiper.tsx @@ -1,5 +1,5 @@ import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isNumber, isObject } from 'lodash-es'; +import { isNumber } from 'lodash-es'; import classNames from 'classnames'; import { Property } from 'csstype'; import useDefaultProps from '../hooks/useDefaultProps'; @@ -23,7 +23,6 @@ enum SwiperStatus { IDLE = 'idle', // 空闲状态 SWITCHING = 'switching', // 切换状态 STARTDRAG = 'startdrag', // 开始拖拽 - ENDDRAG = 'enddrag', // 结束拖拽 } // swiper组件的动态style @@ -113,7 +112,7 @@ const Swiper = forwardRefWithStatics( const nav = navigation as SwiperNavigation; return nav?.minShowNum ? items.current.length > nav?.minShowNum : true; } - return isObject(navigation); + return typeof navigation === 'string'; }, [isSwiperNavigation, navigation]); const isBottomPagination = useMemo(() => { @@ -475,9 +474,6 @@ const Swiper = forwardRefWithStatics( case SwiperStatus.STARTDRAG: nextIndex.current = previousIndex.current; break; - case SwiperStatus.ENDDRAG: - setSwiperStatus(SwiperStatus.IDLE); - break; } }, [autoplay, directionAxis, duration, enterIdle, enterSwitching, interval, quitSwitching, swiperStatus]); @@ -573,14 +569,26 @@ const Swiper = forwardRefWithStatics( if (!enableNavigation) return ''; if (isSwiperNavigation) { - return ( - <> - {controlsNav(navigation as SwiperNavigation)} - {typeNav(navigation as SwiperNavigation)} - - ); + const controls = controlsNav(navigation as SwiperNavigation); + const type = typeNav(navigation as SwiperNavigation); + if (controls || type) { + return ( + + {controls} + {type} + + ); + } + return ''; } - return isObject(navigation) ? '' : parseTNode(navigation); + return parseTNode(navigation as any); }; return ( diff --git a/src/swiper/__tests__/swiper.test.tsx b/src/swiper/__tests__/swiper.test.tsx new file mode 100644 index 000000000..9227c3130 --- /dev/null +++ b/src/swiper/__tests__/swiper.test.tsx @@ -0,0 +1,577 @@ +import React, { useState } from 'react'; +import { describe, it, expect, render, fireEvent, vi, act, afterEach } from '@test/utils'; +import Swiper from '../index'; + +const prefix = 't'; +const swiperClass = `.${prefix}-swiper`; +const swiperItemClass = `.${prefix}-swiper-item`; +const swiperNavClass = `.${prefix}-swiper-nav`; + +const createRect = (width = 300, height = 200): DOMRect => ({ + width, + height, + top: 0, + left: 0, + bottom: height, + right: width, + x: 0, + y: 0, + toJSON: () => ({}), +}); + +const mockElementMetrics = (width = 300, height = 200) => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(width); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(height); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => createRect(width, height)); +}; + +const flushEffects = async () => { + await act(async () => { + await Promise.resolve(); + }); +}; + +describe('Swiper', () => { + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders dots navigation with controls and triggers nav changes', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + const handleClick = vi.fn(); + + const { container } = render( + + Slide 1 + Slide 2 + Slide 3 + , + ); + + expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--outside'); + expect(container.querySelectorAll(`${swiperNavClass}__dots-item`).length).toBe(3); + expect(container.querySelector(`${swiperItemClass}--active`)).toBeInTheDocument(); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)!; + fireEvent.click(swiperContainer); + expect(handleClick).toHaveBeenCalledWith(0); + + const nextBtn = container.querySelector(`${swiperNavClass}__btn--next`)! as HTMLElement; + fireEvent.click(nextBtn); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'nav' })); + expect(container.querySelectorAll(`${swiperItemClass}--next`).length).toBeGreaterThan(0); + expect(container.querySelector(`${swiperNavClass}__dots-item--active`)).toBeInTheDocument(); + }); + + it('autoplays and loops with fraction navigation', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { getByText } = render( + + One + Two + , + ); + + expect(getByText('1/2')).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'autoplay' })); + expect(getByText('2/2')).toBeInTheDocument(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + + expect(handleChange).toHaveBeenLastCalledWith(0, expect.any(Object)); + expect(getByText('1/2')).toBeInTheDocument(); + }); + + it('respects controlled props and layout styles', async () => { + vi.useFakeTimers(); + mockElementMetrics(240, 180); + const handleChange = vi.fn(); + + const { container, rerender } = render( + + First + Second + Third + , + ); + + const containerEl = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + expect(containerEl.style.left).toBe('24px'); + expect(containerEl.style.right).toBe('16px'); + expect(containerEl.style.flexDirection).toBe('column'); + expect(containerEl.style.height).toBe('200px'); + expect(container.querySelector(`${swiperNavClass}`)).not.toBeInTheDocument(); + + rerender( + + First + Second + Third + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + + expect(handleChange).toHaveBeenCalledWith(2, expect.any(Object)); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Third'); + }); + + it('handles swipe gestures in both directions', async () => { + vi.useFakeTimers(); + mockElementMetrics(300, 250); + const handleChange = vi.fn(); + + const { container } = render( + + Alpha + Beta + Gamma + , + ); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 200, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: -200, clientY: 0 }] }); + expect(swiperContainer.style.transform).toContain('translateX(-100%)'); + expect(container.querySelectorAll(`${swiperItemClass}--next`).length).toBeGreaterThan(0); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: -200, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'touch' })); + handleChange.mockClear(); + + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 20, clientY: 0 }] }); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 20, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'touch' })); + handleChange.mockClear(); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Beta'); + + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: -200, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 220, clientY: 0 }] }); + expect(swiperContainer.style.transform).toContain('translateX(100%)'); + expect(container.querySelectorAll(`${swiperItemClass}--prev`).length).toBeGreaterThan(0); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 220, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15); + }); + + expect(handleChange).toHaveBeenCalledWith(0, expect.objectContaining({ source: 'touch' })); + }); + + it('honors disabled state for navigation and gestures', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { container } = render( + + Left + Right + , + ); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)!; + const baselineCalls = handleChange.mock.calls.length; + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 120, clientY: 0 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 0, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(handleChange).toHaveBeenCalledTimes(baselineCalls); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Left'); + }); + + it('resets vertical swipe when movement is below threshold', async () => { + vi.useFakeTimers(); + mockElementMetrics(240, 360); + const handleChange = vi.fn(); + + const { container } = render( + + Top + Bottom + , + ); + + const baselineCalls = handleChange.mock.calls.length; + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + const startY = 240; + const moveY = 180; + const containerHeight = 360; + + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 0, clientY: startY }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 0, clientY: moveY }] }); + + const expectedPercent = ((moveY - startY) / containerHeight) * 100; + expect(swiperContainer.style.transform).toContain(`translateY(${expectedPercent}%)`); + + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 0, clientY: 180 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(12); + }); + + expect(handleChange.mock.calls.length).toBeGreaterThan(baselineCalls); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('Top'); + }); + + it('supports card layout with string sizing and nav placement configs', async () => { + mockElementMetrics(360, 220); + + const { container, rerender } = render( + + Slide A + Slide B + Slide C + Slide D + , + ); + + const cardContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + expect(cardContainer.style.left).toBe('5%'); + expect(cardContainer.style.right).toBe('12%'); + expect(cardContainer.style.height).toBe('180px'); + + await flushEffects(); + await flushEffects(); + expect(container.querySelector(`${swiperClass}`)).toHaveClass('t-swiper--card'); + + rerender( + + Slide A + Slide B + Slide C + Slide D + , + ); + + await flushEffects(); + await flushEffects(); + }); + + it('normalizes swipe offsets and respects guard fallbacks', async () => { + vi.useFakeTimers(); + const zeroRect = () => createRect(0, 0); + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(undefined as any); + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(undefined as any); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(zeroRect); + + const { container, rerender } = render( + + {/* intentionally empty to hit guards */} + , + ); + + const emptyContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + fireEvent.touchStart(emptyContainer, { touches: [{ clientX: 10, clientY: 5 }] }); + fireEvent.touchMove(emptyContainer, { touches: [{ clientX: 80, clientY: 5 }] }); + fireEvent.touchEnd(emptyContainer, { changedTouches: [{ clientX: 80, clientY: 5 }], touches: [] }); + expect(emptyContainer.style.transform).toBe(''); + + rerender( + + Solo + Partner + , + ); + + const fallbackContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + fireEvent.touchStart(fallbackContainer, { touches: [{ clientX: 100, clientY: 0 }] }); + fireEvent.touchMove(fallbackContainer, { touches: [{ clientX: -260, clientY: 0 }] }); + fireEvent.touchEnd(fallbackContainer, { changedTouches: [{ clientX: -260, clientY: 0 }], touches: [] }); + await flushEffects(); + const swiperItems = Array.from(container.querySelectorAll(`${swiperItemClass}`)); + expect(swiperItems[0]).toHaveTextContent('Solo'); + + vi.restoreAllMocks(); + vi.useFakeTimers(); + mockElementMetrics(320, 200); + + rerender( + + One + Two + Three + , + ); + + const activeContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + const nextBtn = container.querySelector(`${swiperNavClass}__btn--next`)! as HTMLElement; + fireEvent.click(nextBtn); + + fireEvent.touchStart(activeContainer, { touches: [{ clientX: 160, clientY: 0 }] }); + fireEvent.touchMove(activeContainer, { touches: [{ clientX: -120, clientY: 0 }] }); + fireEvent.touchEnd(activeContainer, { changedTouches: [{ clientX: -120, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(25); + }); + + fireEvent.touchStart(activeContainer, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchMove(activeContainer, { touches: [{ clientX: -700, clientY: 0 }] }); + expect(activeContainer.style.transform).toContain('translateX(-100%)'); + fireEvent.touchEnd(activeContainer, { changedTouches: [{ clientX: -700, clientY: 0 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(25); + }); + }); + it('recomputes when items are removed dynamically', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + + const Demo = () => { + const [visible, setVisible] = useState(true); + return ( +
+ + {visible && Shown} + Persistent + + +
+ ); + }; + + const { getByText } = render(); + + fireEvent.click(getByText('toggle')); + + await act(async () => { + vi.advanceTimersByTime(20); + }); + + expect(document.querySelectorAll(`${swiperItemClass}`).length).toBe(1); + }); + + it('clamps navigation at bounds without loop and falls back for custom navigation slot', async () => { + vi.useFakeTimers(); + mockElementMetrics(); + const handleChange = vi.fn(); + + const { container, rerender } = render( + + First + Second + , + ); + + const initialCalls = handleChange.mock.calls.length; + const prevBtn = container.querySelector(`${swiperNavClass}__btn--prev`)! as HTMLElement; + fireEvent.click(prevBtn); + + await act(async () => { + await vi.advanceTimersByTimeAsync(18); + }); + + expect(handleChange.mock.calls.length).toBeGreaterThan(initialCalls); + expect(handleChange).toHaveBeenLastCalledWith(0, expect.objectContaining({ source: 'nav' })); + expect(container.querySelector(`${swiperItemClass}--active`)).toHaveTextContent('First'); + + rerender( + slot} + onChange={handleChange} + > + First + Second + , + ); + + expect(container.querySelector('[data-testid="custom-nav"]')).not.toBeInTheDocument(); + }); + + it('handles vertical direction with controls and height from items', async () => { + vi.useFakeTimers(); + mockElementMetrics(300, 400); + const handleChange = vi.fn(); + + const { container } = render( + + Top + Bottom + Another + , + ); + + expect(container.querySelector(`${swiperClass}__container--card`)).toHaveStyle({ flexDirection: 'column' }); + expect(container.querySelector(`${swiperNavClass}__btn`)).not.toBeInTheDocument(); // controls not shown in vertical + expect(container.querySelectorAll(`${swiperNavClass}__dots-item`).length).toBe(3); + + const swiperContainer = container.querySelector(`${swiperClass}__container--card`)! as HTMLElement; + fireEvent.touchStart(swiperContainer, { touches: [{ clientX: 0, clientY: 200 }] }); + fireEvent.touchMove(swiperContainer, { touches: [{ clientX: 0, clientY: 50 }] }); + fireEvent.touchEnd(swiperContainer, { changedTouches: [{ clientX: 0, clientY: 50 }], touches: [] }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + + expect(handleChange).toHaveBeenCalledWith(1, expect.objectContaining({ source: 'touch' })); + }); + + it('renders without navigation and uses height from first item', async () => { + mockElementMetrics(300, 200); + const mockRect = { + width: 300, + height: 150, + top: 0, + left: 0, + bottom: 150, + right: 300, + x: 0, + y: 0, + toJSON: () => ({}), + }; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(mockRect); + + const { container } = render( + + Item 1 + Item 2 + , + ); + + expect(container.querySelector(`${swiperNavClass}`)).not.toBeInTheDocument(); + expect(container.querySelector(`${swiperClass}__container--card`)).toHaveStyle({ height: '150px' }); + + vi.restoreAllMocks(); + }); + + it('renders custom navigation as string', async () => { + const { container } = render( + + Slide 1 + , + ); + + expect(container.textContent).toContain('Custom Nav'); + }); + + it('handles navigation object without type property', async () => { + const { container } = render( + + Slide 1 + Slide 2 + , + ); + + expect(container.querySelector(`${swiperNavClass}`)).toBeInTheDocument(); + expect(container.querySelector(`${swiperNavClass}__btn`)).toBeInTheDocument(); + }); + + it('exposes SwiperItem as static property', () => { + expect(Swiper.SwiperItem).toBeDefined(); + expect(typeof Swiper.SwiperItem).toBe('function'); + // Test that static properties are properly hoisted + expect(Object.keys(Swiper)).toContain('SwiperItem'); + expect(Object.getOwnPropertyDescriptor(Swiper, 'SwiperItem')).toBeDefined(); + // Test property descriptor attributes + const descriptor = Object.getOwnPropertyDescriptor(Swiper, 'SwiperItem'); + expect(descriptor?.writable).toBe(true); + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.configurable).toBe(true); + }); +});