diff --git a/site/test-coverage.js b/site/test-coverage.js index 8886ceacf..9fdee523a 100644 --- a/site/test-coverage.js +++ b/site/test-coverage.js @@ -54,7 +54,7 @@ module.exports = { stepper: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, 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%' }, + swipeCell: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, swiper: { statements: '3.77%', branches: '0.9%', functions: '1.4%', lines: '3.89%' }, switch: { statements: '100%', branches: '100%', functions: '100%', lines: '100%' }, tabBar: { statements: '10%', branches: '0%', functions: '0%', lines: '10.81%' }, diff --git a/src/swipe-cell/SwipeCell.tsx b/src/swipe-cell/SwipeCell.tsx index 5175068a5..f6b243619 100644 --- a/src/swipe-cell/SwipeCell.tsx +++ b/src/swipe-cell/SwipeCell.tsx @@ -25,6 +25,28 @@ export interface SwipeCellProps extends TdSwipeCellProps, StyledProps {} const threshold = '50%'; +export const syncOpenedState = ( + rootRef: React.RefObject, + opened: SwipeCellProps['opened'], + getOpenedSide: (opened: SwipeCellProps['opened']) => SideType | undefined, + expand: (side: SideType) => void, + close: () => void, +) => { + if (!rootRef.current) return; + + const side = getOpenedSide(opened); + + if (side === 'left' || side === 'right') { + // 初始化 expanded,等待 dom 加载完,获取 left/right 宽度后无动画设置展开状态 + // 防止 left/right 为列表时,获取真实宽度有误 + setTimeout(() => { + expand(side); + }, 100); + } else { + close(); + } +}; + const SwipeCell = forwardRef((originProps, ref) => { const props = useDefaultProps(originProps, swipeCellDefaultProps); @@ -49,10 +71,10 @@ const SwipeCell = forwardRef((originProps, ref) => return; } if (isArray(opened)) { - if (open[1] && rightRef.current) { + if (opened[1] && rightRef.current) { return 'right'; } - if (open[0] && leftRef.current) { + if (opened[0] && leftRef.current) { return 'left'; } } @@ -130,7 +152,7 @@ const SwipeCell = forwardRef((originProps, ref) => } else { close(); } - window.setTimeout(() => { + setTimeout(() => { ctx.dragging = false; }); } else { @@ -156,18 +178,7 @@ const SwipeCell = forwardRef((originProps, ref) => })); useLayoutEffect(() => { - if (!rootRef.current) return; - const side = getOpenedSide(opened); - - if (['left', 'right'].includes(side)) { - // 初始化 expanded,等待 dom 加载完,获取 left/right 宽度后无动画设置展开状态 - // 防止 left/right 为列表时,获取真实宽度有误 - setTimeout(() => { - expand(side as SideType); - }, 100); - } else { - close(); - } + syncOpenedState(rootRef, opened, getOpenedSide, expand, close); // 可以保证expand,close正常执行 // eslint-disable-next-line react-hooks/exhaustive-deps }, [opened, rootRef.current]); diff --git a/src/swipe-cell/__tests__/swipe-cell.test.tsx b/src/swipe-cell/__tests__/swipe-cell.test.tsx new file mode 100644 index 000000000..31e511841 --- /dev/null +++ b/src/swipe-cell/__tests__/swipe-cell.test.tsx @@ -0,0 +1,778 @@ +import { describe, it, expect, render, fireEvent, act } from '@test/utils'; +import { vi, beforeEach, afterEach } from 'vitest'; +import React, { useRef } from 'react'; +import SwipeCell, { SwipeCellRef, syncOpenedState } from '../SwipeCell'; + +// mock use-gesture to capture handler/options for unit branch coverage +const dragStore: Array = []; +vi.mock('@use-gesture/react', () => ({ + useDrag: (handler: any, opts: any) => { + dragStore.push({ handler, opts }); + // return a bind function; component spreads its props, but tests don't rely on them + return () => ({}); + }, +})); + +const leftActions = [ + { text: '左1', className: 'btn-left-1', style: { background: 'red' }, onClick: vi.fn() }, + { text: '左2', className: 'btn-left-2', style: { background: 'blue' }, onClick: vi.fn() }, +]; +const rightActions = [ + { text: '右1', className: 'btn-right-1', style: { background: 'green' }, onClick: vi.fn() }, + { text: '右2', className: 'btn-right-2', style: { background: 'yellow' }, onClick: vi.fn() }, +]; + +describe('SwipeCell', () => { + // ensure clean store for each test + beforeEach(() => { + dragStore.length = 0; + vi.useRealTimers(); + }); + + it('useClickAway does nothing when already closed (x===0)', () => { + const onChange = vi.fn(); + const { container } = render( +
+ 内容
} opened={false} onChange={onChange} /> + + , + ); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + // initial is closed + expect(wrapper.style.transform).toContain('translateX(0px)'); + // touch outside should not trigger close/onChange when x===0 + const btn = container.querySelector('button')!; + fireEvent.touchStart(btn); + expect(wrapper.style.transform).toContain('translateX(0px)'); + expect(onChange).not.toHaveBeenCalled(); + }); + + afterEach(() => { + // flush any timeouts scheduled inside component (e.g., expand delay, dragging reset, sure animation) + vi.useFakeTimers(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('renders content and actions', () => { + const { getByText } = render( + 内容} opened />, + ); + expect(getByText('内容')).toBeInTheDocument(); + expect(getByText('左1')).toBeInTheDocument(); + expect(getByText('右1')).toBeInTheDocument(); + }); + + it('renders with custom className and style', () => { + const { container } = render( + 内容} />, + ); + const element = container.querySelector('.custom-class'); + expect(element).toBeInTheDocument(); + expect(element).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + }); + + it('renders content as TNode', () => { + const content = () =>
动态内容
; + const { getByText } = render(); + expect(getByText('动态内容')).toBeInTheDocument(); + }); + + it('renders actions as TNode', () => { + const actions = () =>
自定义操作
; + const { getByText } = render(); + expect(getByText('自定义操作')).toBeInTheDocument(); + }); + + it('renders action with icon and text', () => { + const action = { text: '操作', icon: 图标, onClick: vi.fn() }; + const { getByText } = render(); + expect(getByText('操作')).toBeInTheDocument(); + expect(getByText('图标')).toBeInTheDocument(); + }); + + it('calls onChange when opened changes', async () => { + const onChange = vi.fn(); + const { rerender } = render( + 内容} + opened={false} + onChange={onChange} + />, + ); + await act(async () => { + rerender( + 内容} + opened={[false, true]} + onChange={onChange} + />, + ); + }); + await act(async () => { + await new Promise((resolve) => { + setTimeout(() => resolve(), 200); + }); + }); + expect(onChange).toHaveBeenCalledWith('right'); + }); + + it('supports opened as array [true, false]', () => { + const { getByText } = render( + 内容} opened={[true, false]} />, + ); + expect(getByText('左1')).toBeInTheDocument(); + }); + + it('supports opened as array [false, true]', () => { + const { getByText } = render( + 内容} opened={[false, true]} />, + ); + expect(getByText('右1')).toBeInTheDocument(); + }); + + it('supports opened as boolean true with right', () => { + const { getByText } = render(内容} opened={true} />); + expect(getByText('右1')).toBeInTheDocument(); + }); + + it('supports opened as boolean true with left', () => { + const { getByText } = render(内容} opened={true} />); + expect(getByText('左1')).toBeInTheDocument(); + }); + + it('opened=true with both sides prefers right', async () => { + vi.useFakeTimers(); + const onChange = vi.fn(); + const { container } = render( + 内容} opened onChange={onChange} />, + ); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 110, configurable: true }); + await act(async () => { + vi.advanceTimersByTime(120); + }); + expect(wrapper.style.transform).toContain('translateX(-110px)'); + expect(onChange).toHaveBeenCalledWith('right'); + vi.useRealTimers(); + }); + + it('disables swipe when disabled', () => { + const { container } = render( + 内容} disabled />, + ); + expect(container.querySelector('.t-swipe-cell')).toBeTruthy(); + }); + + it('calls onClick for action', () => { + const onClick = vi.fn(); + const action = { text: '操作', onClick: vi.fn() }; + const { getByText } = render(内容} onClick={onClick} opened />); + fireEvent.click(getByText('操作')); + expect(action.onClick).toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledWith(action, 'right'); + }); + + it('action renders custom className and merges inline style', () => { + const { container, getByText } = render( + 内容} opened />, + ); + // left first action has custom className + const leftBtn = container.querySelector('.btn-left-1') as HTMLElement; + expect(leftBtn).toBeTruthy(); + // style height:100% merged with background + expect(leftBtn.getAttribute('style')).toContain('background: red'); + // right first action has custom className + expect(container.querySelector('.btn-right-1')).toBeTruthy(); + expect(getByText('左1')).toBeInTheDocument(); + expect(getByText('右1')).toBeInTheDocument(); + }); + + it('shows sure content and resets after confirm', async () => { + function TestSure() { + const ref = useRef(null); + const sure =
ref.current.close()}>确认删除?
; + const action = { text: '删除', sure }; + return 内容} opened />; + } + const { getByText, queryByText } = render(); + fireEvent.click(getByText('删除')); + expect(getByText('确认删除?')).toBeInTheDocument(); + fireEvent.click(getByText('确认删除?')); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + }); + expect(queryByText('确认删除?')).not.toBeInTheDocument(); + }); + + it('ref expand/close works', () => { + function TestRef() { + const ref = useRef(null); + return ( + <> + 内容} /> + + + + ); + } + const { getByText } = render(); + fireEvent.click(getByText('expand')); + fireEvent.click(getByText('close')); + // 只验证无异常 + expect(getByText('内容')).toBeInTheDocument(); + }); + + it('expand defaults to right and close clears sure overlay', () => { + vi.useFakeTimers(); + const ref = React.createRef(); + const action = { text: '删除', sure:
确认立即删除?
}; + const { container, getByText, queryByText } = render( + 内容} />, + ); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 90, configurable: true }); + act(() => { + ref.current.expand(); + }); + expect(wrapper.style.transform).toContain('translateX(-90px)'); + fireEvent.click(getByText('删除')); + expect(getByText('确认立即删除?')).toBeInTheDocument(); + act(() => { + ref.current.close(); + }); + act(() => { + vi.advanceTimersByTime(350); + }); + expect(queryByText('确认立即删除?')).not.toBeInTheDocument(); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('click away closes cell', () => { + const { getByText, container } = render( +
+ 内容
} opened /> + + , + ); + fireEvent.touchStart(container.querySelector('button')); + expect(getByText('内容')).toBeInTheDocument(); + }); + + it('drag interaction triggers expand/close', () => { + // 这里只能模拟 drag 回调,use-gesture 行为需 e2e + const { getByText } = render(内容} opened={false} />); + // 直接 setX/expand/close 已在其他用例覆盖 + expect(getByText('内容')).toBeInTheDocument(); + }); + + it('useDrag last=false updates translateX (setX branch)', () => { + const { container } = render(内容} opened={false} />); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + expect(wrapper).toBeTruthy(); + const latest = dragStore[dragStore.length - 1]; + // simulate dragging but not ending + act(() => { + latest.handler({ + last: false, + offset: [30, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [1, 0], + }); + }); + // style should reflect x=30 + expect(wrapper.style.transform).toContain('translateX(30px)'); + }); + + it('useDrag end expands to right when dragged left beyond threshold', () => { + const { container } = render(内容} />); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + // mock widths for getSideOffsetX + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + const latest = dragStore[dragStore.length - 1]; + // drag end towards left + act(() => { + latest.handler({ + last: true, + offset: [-60, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [-1, 0], + }); + }); + expect(wrapper.style.transform).toContain('translateX(-100px)'); + }); + + it('useDrag end expands to left when dragged right beyond threshold', () => { + const { container } = render(内容} />); + const leftEl = container.querySelector('.t-swipe-cell__left') as HTMLElement; + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + Object.defineProperty(leftEl, 'clientWidth', { value: 80, configurable: true }); + const latest = dragStore[dragStore.length - 1]; + act(() => { + latest.handler({ + last: true, + offset: [60, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [1, 0], + }); + }); + expect(wrapper.style.transform).toContain('translateX(80px)'); + }); + + it('useDrag end closes when below threshold', () => { + const { container } = render(内容} />); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + const latest = dragStore[dragStore.length - 1]; + act(() => { + latest.handler({ + last: true, + offset: [-10, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [-1, 0], + }); + }); + expect(wrapper.style.transform).toContain('translateX(0px)'); + }); + + it('useDrag end with both sides and below threshold closes via nearest=0 branch', () => { + const { container } = render( + 内容} opened={false} />, + ); + const leftEl = container.querySelector('.t-swipe-cell__left') as HTMLElement; + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + // mock widths for bounds: left=80, right=120 + Object.defineProperty(leftEl, 'clientWidth', { value: 80, configurable: true }); + Object.defineProperty(rightEl, 'clientWidth', { value: 120, configurable: true }); + const latest = dragStore[dragStore.length - 1]; + // drag slightly left so target ends near 0; with direction -1 and 50% threshold, nearest should be 0 + act(() => { + latest.handler({ + last: true, + offset: [-10, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [-1, 0], + }); + }); + expect(wrapper.style.transform).toContain('translateX(0px)'); + }); + + it('useDrag end with zero offset closes via zero-branch', () => { + const { container } = render(内容} />); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 120, configurable: true }); + const latest = dragStore[dragStore.length - 1]; + act(() => { + latest.handler({ + last: true, + offset: [0, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [-1, 0], + }); + }); + expect(wrapper.style.transform).toContain('translateX(0px)'); + }); + + it('onClickCapture stops propagation while dragging', () => { + const parentClick = vi.fn(); + const { container } = render( +
+ 内容
} /> + , + ); + const root = container.querySelector('.t-swipe-cell') as HTMLElement; + const latest = dragStore[dragStore.length - 1]; + // mark dragging true without scheduling reset + act(() => { + latest.handler({ last: false, offset: [5, 0], lastOffset: [0, 0], velocity: [0, 0], direction: [1, 0] }); + }); + fireEvent.click(root); + expect(parentClick).not.toHaveBeenCalled(); + }); + + it('useClickAway closes when touching outside', async () => { + vi.useFakeTimers(); + const onChange = vi.fn(); + const { container } = render( +
+ 内容
} opened onChange={onChange} /> + + , + ); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + // wait for useLayoutEffect expand timeout (100ms) + await act(async () => { + vi.advanceTimersByTime(120); + }); + expect(wrapper.style.transform).toContain('translateX(-100px)'); + // click away via touchstart on outside button + const btn = container.querySelector('button')!; + fireEvent.touchStart(btn); + // closing is sync setX(0) + expect(wrapper.style.transform).toContain('translateX(0px)'); + expect(onChange).toHaveBeenCalledWith(undefined); + vi.useRealTimers(); + }); + + it('left sure sets initial transform to translateX(-100%)', () => { + const left = [{ text: '删除', sure: '确认左侧删除?' }]; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('删除')); + const overlay = getByText('确认左侧删除?') as HTMLElement; + // transform set immediately before timeout flips to none + expect(overlay.style.transform).toContain('-100%'); + }); + + it('right sure sets initial transform to translateX(100%)', () => { + const right = [{ text: '删除', sure: '确认右侧删除?' }]; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('删除')); + const overlay = getByText('确认右侧删除?') as HTMLElement; + expect(overlay.style.transform).toContain('100%'); + }); + + it('sure transform resets to none after timeout', async () => { + vi.useFakeTimers(); + const right = [{ text: '删除', sure: '确认右侧删除?' }]; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('删除')); + // immediately set to translateX(100%) then scheduled to reset to none + const overlay = getByText('确认右侧删除?') as HTMLElement; + expect(overlay.style.transform).toMatch(/100%/); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(overlay.style.transform).toBe('none'); + vi.useRealTimers(); + }); + + it('useDrag options.bounds returns correct left/right', () => { + const { container } = render(内容} />); + const leftEl = container.querySelector('.t-swipe-cell__left') as HTMLElement; + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(leftEl, 'clientWidth', { value: 80, configurable: true }); + Object.defineProperty(rightEl, 'clientWidth', { value: 120, configurable: true }); + const latest = dragStore[dragStore.length - 1]; + const bounds = latest.opts.bounds(); + expect(bounds.left).toBe(-120); + expect(bounds.right).toBe(80); + // additional option assertions + expect(latest.opts.axis).toBe('x'); + expect(latest.opts.preventScroll).toBe(true); + expect(latest.opts.pointer.touch).toBe(true); + }); + + it('onChange only fires when side changes', () => { + const onChange = vi.fn(); + function TestRef() { + const ref = useRef(null); + return ( + <> + 内容} onChange={onChange} /> + + + ); + } + const { getByText, container } = render(); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 100, configurable: true }); + fireEvent.click(getByText('expR')); + fireEvent.click(getByText('expR')); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('drag end resets dragging flag allowing click to bubble', async () => { + vi.useFakeTimers(); + const parentClick = vi.fn(); + const { container } = render( +
+ 内容
} /> + , + ); + const latest = dragStore[dragStore.length - 1]; + // perform a drag end to schedule ctx.dragging reset + act(() => { + latest.handler({ + last: true, + offset: [-50, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [-1, 0], + }); + }); + // run queued setTimeout(() => ctx.dragging = false) + await act(async () => { + vi.advanceTimersByTime(1); + }); + const root = container.querySelector('.t-swipe-cell') as HTMLElement; + fireEvent.click(root); + expect(parentClick).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('useDrag options.from returns current x', () => { + const { container } = render(内容} />); + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 90, configurable: true }); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + const latest = dragStore[dragStore.length - 1]; + // expand to right via drag end + act(() => { + latest.handler({ + last: true, + offset: [-60, 0], + lastOffset: [0, 0], + velocity: [0, 0], + direction: [-1, 0], + }); + }); + expect(wrapper.style.transform).toContain('translateX(-90px)'); + // now from() in the latest registration should reflect current x + const newLatest = dragStore[dragStore.length - 1]; + const from = newLatest.opts.from(); + expect(from).toEqual([-90, 0]); + }); + + it('useDrag enabled is false when disabled', () => { + render(内容} disabled />); + const latest = dragStore[dragStore.length - 1]; + expect(latest.opts.enabled).toBe(false); + }); + + it('ref expand to side without actions keeps x=0', () => { + function TestRef() { + const ref = useRef(null); + return ( + <> + 内容} /> + + + ); + } + const { getByText, container } = render(); + fireEvent.click(getByText('expand-left')); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + expect(wrapper.style.transform).toContain('translateX(0px)'); + }); + + it('opened=true with no actions does not move (close branch)', async () => { + vi.useFakeTimers(); + const { container } = render(内容} opened />); + // wait beyond 100ms expand timeout; since no left/right, it should close to 0 + await act(async () => { + vi.advanceTimersByTime(150); + }); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + expect(wrapper.style.transform).toContain('translateX(0px)'); + vi.useRealTimers(); + }); + + it('opened array selects correct side via getOpenedSide', async () => { + vi.useFakeTimers(); + const onChange = vi.fn(); + const { container, rerender } = render( + 内容} + opened={[true, false]} + onChange={onChange} + />, + ); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + const leftEl = container.querySelector('.t-swipe-cell__left') as HTMLElement; + Object.defineProperty(leftEl, 'clientWidth', { value: 80, configurable: true }); + await act(async () => { + vi.advanceTimersByTime(120); + }); + expect(wrapper.style.transform).toContain('translateX(80px)'); + expect(onChange).toHaveBeenCalledWith('left'); + + const rightEl = container.querySelector('.t-swipe-cell__right') as HTMLElement; + Object.defineProperty(rightEl, 'clientWidth', { value: 120, configurable: true }); + rerender( + 内容} + opened={[false, true]} + onChange={onChange} + />, + ); + await act(async () => { + vi.advanceTimersByTime(120); + }); + expect(wrapper.style.transform).toContain('translateX(-120px)'); + expect(onChange).toHaveBeenLastCalledWith('right'); + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('renders sure content when curSure has content', () => { + // 为了测试 renderSureContent,需要模拟 curSure 状态 + // 但由于是内部状态,难以直接测试,间接通过 sure 功能测试 + const sureText = '确认删除?'; + const action = { text: '删除', sure: sureText }; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('删除')); + expect(getByText(sureText)).toBeInTheDocument(); + }); + + it('handles action with sure as TNode', () => { + const sureNode =
确认节点
; + const action = { text: '删除', sure: sureNode }; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('删除')); + expect(getByText('确认节点')).toBeInTheDocument(); + }); + + it('handles action click without onClick', () => { + const action = { text: '操作' }; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('操作')); + // 无异常 + expect(getByText('操作')).toBeInTheDocument(); + }); + + it('handles onChange not provided', () => { + const { rerender } = render(内容} opened={false} />); + rerender(内容} opened={true} />); + // 无异常 + expect(true).toBe(true); + }); + + it('handles onClick not provided', () => { + const action = { text: '操作', onClick: vi.fn() }; + const { getByText } = render(内容} opened />); + fireEvent.click(getByText('操作')); + expect(action.onClick).toHaveBeenCalled(); + }); + + it('handles invalid opened value in useLayoutEffect', async () => { + // Test with empty array which should trigger close() + const { container } = render(内容} opened={[]} />); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + }); + // Should call close() when opened array is empty + expect(container.querySelector('.t-swipe-cell')).toBeTruthy(); + }); + + it('handles opened as empty array', async () => { + const { container } = render( + 内容} opened={[]} />, + ); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + }); + // Should call close() when opened array is empty + expect(container.querySelector('.t-swipe-cell')).toBeTruthy(); + }); + + it('handles opened as false', async () => { + const { container } = render( + 内容} opened={false} />, + ); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 200); + }); + }); + // Should call close() when opened is false + expect(container.querySelector('.t-swipe-cell')).toBeTruthy(); + }); + + it('handles opened with invalid non-array value gracefully', async () => { + vi.useFakeTimers(); + const { container } = render( + 内容} opened={'invalid' as any} />, + ); + const wrapper = container.querySelector('.t-swipe-cell__wrapper') as HTMLElement; + await act(async () => { + vi.advanceTimersByTime(150); + }); + // guard path should close cell without throwing + expect(wrapper.style.transform).toContain('translateX(0px)'); + vi.useRealTimers(); + }); + + it('renders without sure content when curSure is empty', () => { + const { container } = render(内容} opened />); + // renderSureContent should return null when curSure.content is empty + const sureElements = container.querySelectorAll('[style*="position: absolute"]'); + expect(sureElements.length).toBe(0); + }); + + it('handles drag end with velocity and direction', () => { + // This is a simplified test to cover the drag logic branches + // In a real scenario, we'd need to mock @use-gesture/react properly + const { getByText } = render(内容} />); + expect(getByText('内容')).toBeInTheDocument(); + // The actual drag testing would require e2e tests or more complex mocking + }); + + it('handles sure content transform animation', async () => { + function TestSureTransform() { + const ref = useRef(null); + const sure =
确认删除?
; + const action = { text: '删除', sure }; + return 内容} opened />; + } + const { getByText } = render(); + fireEvent.click(getByText('删除')); + // Wait for the transform animation timeout + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + }); + expect(getByText('确认删除?')).toBeInTheDocument(); + }); + + it('syncOpenedState returns early when root ref is missing', () => { + vi.useFakeTimers(); + const expand = vi.fn(); + const close = vi.fn(); + syncOpenedState({ current: null } as any, [true, false], () => 'left', expand, close); + expect(expand).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('syncOpenedState triggers expand when side available', () => { + vi.useFakeTimers(); + const expand = vi.fn(); + const close = vi.fn(); + syncOpenedState({ current: {} } as any, [true, false], () => 'right', expand, close); + expect(expand).not.toHaveBeenCalled(); + vi.advanceTimersByTime(120); + expect(expand).toHaveBeenCalledWith('right'); + expect(close).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +});