diff --git a/site/test-coverage.js b/site/test-coverage.js index 2627522e..8b519645 100644 --- a/site/test-coverage.js +++ b/site/test-coverage.js @@ -59,7 +59,7 @@ module.exports = { switch: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, tabBar: { statements: '100%', branches: '93.18%', functions: '100%', lines: '100%' }, table: { statements: '100%', branches: '90%', functions: '100%', lines: '100%' }, - tabs: { statements: '43.22%', branches: '18.75%', functions: '56%', lines: '45.07%' }, + tabs: { statements: '99.35%', branches: '97.5%', functions: '100%', lines: '100%' }, tag: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, textarea: { statements: '98.64%', branches: '95%', functions: '93.33%', lines: '100%' }, toast: { statements: '98.73%', branches: '100%', functions: '94.11%', lines: '98.66%' }, diff --git a/src/tabs/__tests__/tab-panel.test.tsx b/src/tabs/__tests__/tab-panel.test.tsx new file mode 100644 index 00000000..e1af5278 --- /dev/null +++ b/src/tabs/__tests__/tab-panel.test.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { describe, it, expect, render, vi, fireEvent, beforeEach, act } from '@test/utils'; +import { Tabs, TabPanel } from '../index'; + +const prefix = 't'; +const name = `.${prefix}-tabs`; +const panelClass = `.${prefix}-tab-panel`; + +describe('TabPanel', () => { + beforeEach(() => { + Object.defineProperty(Element.prototype, 'scrollTo', { + configurable: true, + writable: true, + value: vi.fn(), + }); + }); + + describe('props', () => { + it(': value', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + // 根据 value 确定激活状态 + const panels = container.querySelectorAll(panelClass); + expect(panels[0]).toHaveStyle({ display: 'block' }); + expect(panels[1]).toHaveStyle({ display: 'none' }); + }); + + it(': label', () => { + const { queryByText } = render( + + + Content + + , + ); + + expect(queryByText('自定义标签')).toBeInTheDocument(); + }); + + it(': children', () => { + const { queryByText } = render( + + + 子元素内容 + + , + ); + + expect(queryByText('子元素内容')).toBeInTheDocument(); + }); + + it(': disabled', () => { + const onChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const tab2 = container.querySelectorAll(`${name}__item`)[1]; + expect(tab2).toHaveClass(`${prefix}-tabs__item--disabled`); + + fireEvent.click(tab2); + expect(onChange).not.toHaveBeenCalled(); + }); + it(': lazy', async () => { + const { container, queryByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + expect(queryByText('Content 2')).not.toBeInTheDocument(); + const tab2 = container.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(tab2); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }); + + expect(queryByText('Content 2')).toBeInTheDocument(); + }); + + it(': destroyOnHide', async () => { + const { container, queryByText } = render( + + + Content 1 + + + Content 2 + + , + ); + + expect(queryByText('Content 1')).toBeInTheDocument(); + + const tab2 = container.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(tab2); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }); + + expect(queryByText('Content 2')).toBeInTheDocument(); + expect(queryByText('Content 1')).not.toBeInTheDocument(); + }); + + it(': badgeProps', () => { + const { container } = render( + + + Content 1 + + , + ); + + expect(container.querySelector('.t-badge')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/tabs/__tests__/tabs.test.tsx b/src/tabs/__tests__/tabs.test.tsx new file mode 100644 index 00000000..d0267e4c --- /dev/null +++ b/src/tabs/__tests__/tabs.test.tsx @@ -0,0 +1,670 @@ +import React from 'react'; +import { describe, it, expect, render, vi, fireEvent, beforeEach, act } from '@test/utils'; +import { Tabs, TabPanel } from '../index'; + +const prefix = 't'; +const name = `.${prefix}-tabs`; + +describe('Tabs', () => { + beforeEach(() => { + Object.defineProperty(Element.prototype, 'scrollTo', { + configurable: true, + writable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get() { + return 100; + }, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetLeft', { + configurable: true, + get() { + return 50; + }, + }); + }); + + describe('props', () => { + it(': bottomLineMode', async () => { + const modes = ['fixed', 'auto', 'full'] as const; + + modes.forEach((mode) => { + const { container } = render( + + + Content 1 + + , + ); + expect(container.querySelector(`${name}__track`)).toBeTruthy(); + }); + + // full 模式 + const { container: fullContainer } = render( + + + Content 1 + + + Content 2 + + , + ); + + const fullTab2 = fullContainer.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(fullTab2); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }); + + expect(fullContainer.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2'); + + // auto 模式 + const { container: autoContainer } = render( + + + Content 1 + + + Content 2 + + , + ); + + const autoTab2 = autoContainer.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(autoTab2); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }); + + expect(autoContainer.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2'); + }); + + it(': children', () => { + const { queryByText } = render( + + + Content 1 + + + Content 2 + + , + ); + expect(queryByText('Tab 1')).toBeInTheDocument(); + expect(queryByText('Content 1')).toBeInTheDocument(); + }); + + it(': list', () => { + const list = [ + { label: 'Item 1', value: 'a' }, + { label: 'Item 2', value: 'b' }, + ]; + const { queryByText: queryByText2 } = render(); + expect(queryByText2('Item 1')).toBeInTheDocument(); + }); + + it(': size', () => { + const { container } = render( + + + Content 1 + + , + ); + expect(container.querySelector('.large')).toBeTruthy(); + }); + + it(': spaceEvenly', () => { + const { container } = render( + + + Content 1 + + , + ); + expect(container.querySelector(`${name}__item--evenly`)).toBeTruthy(); + }); + + it(': theme', () => { + const themes = ['line', 'tag', 'card'] as const; + themes.forEach((theme) => { + const { container } = render( + + + Content 1 + + , + ); + expect(container.querySelector(`${name}__item--${theme}`)).toBeTruthy(); + }); + + const { container } = render( + + + Content 1 + + + Content 2 + + + Content 3 + + , + ); + expect(container.querySelector(`${name}__item-prefix`)).toBeTruthy(); + expect(container.querySelector(`${name}__item-suffix`)).toBeTruthy(); + }); + + it(': value', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2'); + }); + + it(': defaultValue', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1'); + }); + + it(': swipeable', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + const content = container.querySelector(`${name}__content`); + fireEvent.touchStart(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 10, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1'); + }); + + it(': stickyProps', () => { + const { container } = render( + + + Content 1 + + , + ); + expect(container.querySelector(name)).toBeTruthy(); + }); + + it(': animation', async () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 350); + }); + }); + + const tab2 = container.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(tab2); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }); + + expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 2'); + + const tab1 = container.querySelectorAll(`${name}__item`)[0]; + fireEvent.click(tab1); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }); + + expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1'); + }); + + it(': disabled', () => { + const handleChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const disabledTab = container.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(disabledTab); + expect(handleChange).not.toHaveBeenCalled(); + + const activeTab = container.querySelectorAll(`${name}__item`)[0]; + fireEvent.click(activeTab); + expect(handleChange).not.toHaveBeenCalled(); + }); + }); + + describe('events', () => { + it(': onChange', () => { + const handleChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + const tab2 = container.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(tab2); + + expect(handleChange).toHaveBeenCalledWith('2', 'Tab 2'); + }); + + it(': onClick', () => { + const handleClick = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + const tab2 = container.querySelectorAll(`${name}__item`)[1]; + fireEvent.click(tab2); + + expect(handleClick).toHaveBeenCalledWith('2', 'Tab 2'); + }); + }); + + describe('swipe gestures', () => { + it(': swipe left to next tab', () => { + const onChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const content = container.querySelector(`${name}__content`); + // 向左滑动:startX > endX,切换到下一个 tab + fireEvent.touchStart(content, { targetTouches: [{ clientX: 150, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(onChange).toHaveBeenCalledWith('2', 'Tab 2'); + }); + + it(': swipe right from middle tab triggers onChange', () => { + const onChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + + Content 3 + + , + ); + + const content = container.querySelector(`${name}__content`); + // 向右滑动:startX < endX,从中间 tab 切换到上一个 + fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 150, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(onChange).toHaveBeenCalledWith('1', 'Tab 1'); + }); + + it(': swipe right from middle tab', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + + Content 3 + + , + ); + + const content = container.querySelector(`${name}__content`); + fireEvent.touchStart(content, { targetTouches: [{ clientX: 10, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(container.querySelector(name)).toBeTruthy(); + }); + + it(': swipe left from last tab (boundary)', () => { + const onChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const content = container.querySelector(`${name}__content`); + // 从最后一个 tab 向左滑动,不应切换 + fireEvent.touchStart(content, { targetTouches: [{ clientX: 150, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it(': boundary swipe (first tab right)', () => { + const onChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const content = container.querySelector(`${name}__content`); + + fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 150, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it(': vertical swipe ignored', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const content = container.querySelector(`${name}__content`); + // 垂直滑动:dValueY > dValueX,不会触发水平切换 + fireEvent.touchStart(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 110, clientY: 150 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 120, clientY: 250 }] }); + fireEvent.touchEnd(content); + + // 验证组件正常工作 + expect(container.querySelector(name)).toBeTruthy(); + }); + + it(': canMove prevents multiple swipes', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const content = container.querySelector(`${name}__content`); + fireEvent.touchStart(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 10, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(container.querySelector(name)).toBeTruthy(); + }); + + it(': small swipe distance ignored', () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + const content = container.querySelector(`${name}__content`); + fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 30, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 35, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(container.querySelector(name)).toBeTruthy(); + }); + }); + + describe('edge cases', () => { + it(': should handle non-TabPanel children filtering', () => { + const { container, queryByText } = render( + + + Content 1 + +
Not a TabPanel
+ + Content 2 + +
, + ); + + expect(queryByText('Tab 1')).toBeInTheDocument(); + expect(queryByText('Tab 2')).toBeInTheDocument(); + expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1'); + }); + + it(': should handle moveToActiveTab with no active tab', async () => { + const { container } = render( + + + Content 1 + + + Content 2 + + , + ); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + }); + + const tab1 = container.querySelectorAll(`${name}__item`)[0]; + fireEvent.click(tab1); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 10); + }); + }); + + expect(container.querySelector(`${name}__item--active`)).toHaveTextContent('Tab 1'); + }); + + it(': should handle empty items array', () => { + const onChange = vi.fn(); + const { container } = render(); + + const content = container.querySelector(`${name}__content`); + fireEvent.touchStart(content, { targetTouches: [{ clientX: 150, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 100, clientY: 50 }] }); + fireEvent.touchMove(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + fireEvent.touchEnd(content); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it(': should handle navScroll with no active tab early return', async () => { + const { rerender } = render( + + + Content 1 + + , + ); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 350); + }); + }); + + rerender( + + + Content 1 + + , + ); + + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 30); + }); + }); + + fireEvent(window, new Event('resize')); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 30); + }); + }); + }); + + it(': should handle swipe right branch execution', async () => { + const onChange = vi.fn(); + const { container } = render( + + + Content 1 + + + Content 2 + + + Content 3 + + , + ); + + const content = container.querySelector(`${name}__content`); + await act(async () => { + fireEvent.touchStart(content, { targetTouches: [{ clientX: 50, clientY: 50 }] }); + }); + await act(async () => { + fireEvent.touchMove(content, { targetTouches: [{ clientX: 120, clientY: 50 }] }); + }); + await act(async () => { + fireEvent.touchMove(content, { targetTouches: [{ clientX: 240, clientY: 50 }] }); + }); + await act(async () => { + fireEvent.touchEnd(content); + }); + + expect(onChange).toHaveBeenCalledWith('1', 'Tab 1'); + }); + + it(': should handle swipe right branch with list data', async () => { + const onChange = vi.fn(); + const list = [ + { label: 'Tab 1', value: '1' }, + { label: 'Tab 2', value: '2' }, + { label: 'Tab 3', value: '3' }, + ]; + const { container } = render(); + + const content = container.querySelector(`${name}__content`); + await act(async () => { + fireEvent.touchStart(content, { targetTouches: [{ clientX: 30, clientY: 40 }] }); + }); + await act(async () => { + fireEvent.touchMove(content, { targetTouches: [{ clientX: 110, clientY: 40 }] }); + }); + await act(async () => { + fireEvent.touchMove(content, { targetTouches: [{ clientX: 220, clientY: 40 }] }); + }); + await act(async () => { + fireEvent.touchEnd(content); + }); + + expect(onChange).toHaveBeenCalledWith('1', 'Tab 1'); + }); + }); +});