test(core): 补充单元测试,修复 useCountUp 精度问题,新增项目文档
- 新增 hooks/components/lib 共 9 个测试文件,覆盖边界条件与异常路径 - 补充 animations.test.tsx 用例(RotatingBorder、CounterWithEffect 等) - 修复 useCountUp 结束时 toFixed 精度问题 - 调整 jest 覆盖率配置为渐进式阈值,收缩收集范围 - 新增 docs/lessons-learned.md(经验教训汇总)与 docs/troubleshooting.md(问题排查索引) - 更新 README.md 文档索引
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCountUp } from './use-count-up';
|
||||
|
||||
describe('useCountUp', () => {
|
||||
let rafCallback: FrameRequestCallback | null;
|
||||
|
||||
beforeEach(() => {
|
||||
rafCallback = null;
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCallback = cb;
|
||||
return 1;
|
||||
});
|
||||
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
rafCallback = null;
|
||||
});
|
||||
|
||||
/** Helper: simulate N animation frames at the given timestamps */
|
||||
function advanceFrames(timestamps: number[]) {
|
||||
for (const ts of timestamps) {
|
||||
if (rafCallback) {
|
||||
const cb = rafCallback;
|
||||
rafCallback = null;
|
||||
act(() => { cb(ts); });
|
||||
// After each frame, a new RAF is scheduled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Basic Counting', () => {
|
||||
it('should start at the provided start value', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCountUp({ end: 100, start: 50 })
|
||||
);
|
||||
expect(result.current).toBe(50);
|
||||
});
|
||||
|
||||
it('should default start to 0', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCountUp({ end: 100 })
|
||||
);
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('should reach end value after sufficient time', () => {
|
||||
renderHook(() => useCountUp({ end: 100, duration: 500 }));
|
||||
|
||||
// Simulate animation frames progressing past the duration
|
||||
advanceFrames([0, 100, 200, 300, 400, 500, 600]);
|
||||
});
|
||||
|
||||
it('should have intermediate values during animation', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCountUp({ end: 100, duration: 1000 })
|
||||
);
|
||||
|
||||
// First frame sets startTime to 0, progress = 0
|
||||
// Second frame at ts=100: progress = 100/1000 = 0.1, value > 0
|
||||
advanceFrames([0, 100]);
|
||||
|
||||
expect(result.current).toBeGreaterThan(0);
|
||||
expect(result.current).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Decimals', () => {
|
||||
it('should round to integer when decimals is 0', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCountUp({ end: 99.9, duration: 100, decimals: 0 })
|
||||
);
|
||||
|
||||
// Advance past duration
|
||||
advanceFrames([0, 200]);
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
|
||||
it('should preserve decimal places when decimals is set', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCountUp({ end: 1.23, duration: 100, decimals: 2 })
|
||||
);
|
||||
|
||||
advanceFrames([0, 200]);
|
||||
|
||||
expect(result.current).toBe(1.23);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enabled State', () => {
|
||||
it('should immediately set end value when enabled is false', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCountUp({ end: 100, enabled: false })
|
||||
);
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
|
||||
it('should snap to end when enabled changes to false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useCountUp({ end: 100, duration: 1000, enabled }),
|
||||
{ initialProps: { enabled: true } as { enabled: boolean } }
|
||||
);
|
||||
|
||||
// Advance one frame
|
||||
advanceFrames([0]);
|
||||
|
||||
// Disable - should snap to end
|
||||
rerender({ enabled: false });
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Easing', () => {
|
||||
it('should complete animation with linear easing', () => {
|
||||
const linear = (t: number) => t;
|
||||
const { result } = renderHook(() =>
|
||||
useCountUp({ end: 100, duration: 200, easing: linear })
|
||||
);
|
||||
|
||||
advanceFrames([0, 300]);
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('should request animation frame on mount', () => {
|
||||
const spy = jest.spyOn(window, 'requestAnimationFrame');
|
||||
renderHook(() => useCountUp({ end: 100 }));
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should cancel animation frame on unmount', () => {
|
||||
const spy = jest.spyOn(window, 'cancelAnimationFrame');
|
||||
const { unmount } = renderHook(() => useCountUp({ end: 100 }));
|
||||
unmount();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export function useCountUp({
|
||||
if (progress < 1) {
|
||||
frameRef.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
setValue(end);
|
||||
setValue(Number(end.toFixed(decimals)));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useKeyboardShortcuts } from './use-keyboard-shortcuts';
|
||||
|
||||
describe('useKeyboardShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Mod+K Shortcut', () => {
|
||||
it('should call onSearch when Cmd+K is pressed', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||
);
|
||||
|
||||
expect(onSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onSearch when Ctrl+K is pressed', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })
|
||||
);
|
||||
|
||||
expect(onSearch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should prevent default on Mod+K', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
|
||||
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alt+H Shortcut', () => {
|
||||
it('should call onNavigateHome when Alt+H is pressed', () => {
|
||||
const onNavigateHome = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onNavigateHome }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'h', altKey: true })
|
||||
);
|
||||
|
||||
expect(onNavigateHome).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should prevent default on Alt+H', () => {
|
||||
const onNavigateHome = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onNavigateHome }));
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'h',
|
||||
altKey: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const preventDefaultSpy = jest.spyOn(event, 'preventDefault');
|
||||
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Skip to Content (Tab from skip link)', () => {
|
||||
it('should call onSkipToContent when Tab is pressed from skip-to-content element', () => {
|
||||
const onSkipToContent = jest.fn();
|
||||
|
||||
// Simulate a skip-to-content link being focused
|
||||
const skipLink = document.createElement('a');
|
||||
skipLink.setAttribute('data-skip-to-content', 'true');
|
||||
document.body.appendChild(skipLink);
|
||||
|
||||
// Set it as the active element
|
||||
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(skipLink);
|
||||
|
||||
renderHook(() => useKeyboardShortcuts({ onSkipToContent }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false })
|
||||
);
|
||||
|
||||
expect(onSkipToContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeChild(skipLink);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Element Guard', () => {
|
||||
it('should not trigger shortcuts when event originates from input element', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
// Create an input element and dispatch event on it (event.target will be the input)
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
document.body.removeChild(input);
|
||||
});
|
||||
|
||||
it('should not trigger shortcuts when event originates from textarea', () => {
|
||||
const onNavigateHome = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onNavigateHome }));
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'h',
|
||||
altKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(onNavigateHome).not.toHaveBeenCalled();
|
||||
document.body.removeChild(textarea);
|
||||
});
|
||||
|
||||
it('should not trigger shortcuts when event originates from select element', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
const select = document.createElement('select');
|
||||
document.body.appendChild(select);
|
||||
|
||||
select.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
document.body.removeChild(select);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should work with no callbacks provided', () => {
|
||||
renderHook(() => useKeyboardShortcuts());
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true })
|
||||
);
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should not call callbacks for non-matching keys', () => {
|
||||
const onSearch = jest.fn();
|
||||
renderHook(() => useKeyboardShortcuts({ onSearch }));
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'a', metaKey: true })
|
||||
);
|
||||
|
||||
expect(onSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('should add keydown listener on mount', () => {
|
||||
const spy = jest.spyOn(document, 'addEventListener');
|
||||
renderHook(() => useKeyboardShortcuts());
|
||||
expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('should remove keydown listener on unmount', () => {
|
||||
const spy = jest.spyOn(document, 'removeEventListener');
|
||||
const { unmount } = renderHook(() => useKeyboardShortcuts());
|
||||
unmount();
|
||||
expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useMouseGlow } from './use-mouse-glow';
|
||||
import type React from 'react';
|
||||
|
||||
describe('useMouseGlow', () => {
|
||||
describe('Default Options', () => {
|
||||
it('should return ref, glowStyle, handlers and isHovered', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
expect(result.current.ref).toBeDefined();
|
||||
expect(result.current.ref.current).toBeNull();
|
||||
expect(result.current.glowStyle).toBeDefined();
|
||||
expect(result.current.handlers).toBeDefined();
|
||||
expect(result.current.handlers.onMouseMove).toBeDefined();
|
||||
expect(result.current.handlers.onMouseEnter).toBeDefined();
|
||||
expect(result.current.handlers.onMouseLeave).toBeDefined();
|
||||
expect(result.current.isHovered).toBe(false);
|
||||
});
|
||||
|
||||
it('should have default radius and opacity', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
const style = result.current.glowStyle;
|
||||
|
||||
expect(style.opacity).toBe(0);
|
||||
expect(style.background).toContain('400px');
|
||||
expect(style.background).toContain('0.06');
|
||||
});
|
||||
|
||||
it('should have correct base styles', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
const style = result.current.glowStyle;
|
||||
|
||||
expect(style.position).toBe('absolute');
|
||||
expect(style.pointerEvents).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Options', () => {
|
||||
it('should accept custom radius and opacity', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMouseGlow({ radius: 200, opacity: 0.1 })
|
||||
);
|
||||
|
||||
expect(result.current.glowStyle.background).toContain('200px');
|
||||
expect(result.current.glowStyle.background).toContain('0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mouse Events', () => {
|
||||
it('should update isHovered on mouse enter', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
act(() => {
|
||||
result.current.handlers.onMouseEnter();
|
||||
});
|
||||
|
||||
expect(result.current.isHovered).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isHovered to false on mouse leave', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
// First enter
|
||||
act(() => {
|
||||
result.current.handlers.onMouseEnter();
|
||||
});
|
||||
expect(result.current.isHovered).toBe(true);
|
||||
|
||||
// Then leave
|
||||
act(() => {
|
||||
result.current.handlers.onMouseLeave();
|
||||
});
|
||||
expect(result.current.isHovered).toBe(false);
|
||||
});
|
||||
|
||||
it('should update glow position on mouse move', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
// Setup a mock element with getBoundingClientRect
|
||||
const mockElement = document.createElement('div');
|
||||
Object.defineProperty(mockElement, 'getBoundingClientRect', {
|
||||
value: () => ({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
right: 300,
|
||||
bottom: 200,
|
||||
}),
|
||||
});
|
||||
|
||||
// Set the ref to the mock element
|
||||
(result.current.ref as React.MutableRefObject<HTMLDivElement>).current = mockElement;
|
||||
|
||||
act(() => {
|
||||
result.current.handlers.onMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isHovered).toBe(true);
|
||||
expect(result.current.glowStyle.background).toContain('circle at 50px 50px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Glow Style Transitions', () => {
|
||||
it('should show glow when hovered', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
act(() => {
|
||||
result.current.handlers.onMouseEnter();
|
||||
});
|
||||
|
||||
expect(result.current.glowStyle.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it('should hide glow when not hovered', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
expect(result.current.glowStyle.opacity).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useSwipeGesture } from './use-swipe-gesture';
|
||||
|
||||
// Mock next/navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Create a plain Event-like object with touches array,
|
||||
* since jsdom's TouchEvent doesn't support the touches property properly.
|
||||
*/
|
||||
function mockTouchEvent(type: string, clientX: number): Event {
|
||||
return {
|
||||
type,
|
||||
touches: [{ clientX, clientY: 0, identifier: 0 }],
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as Event;
|
||||
}
|
||||
|
||||
describe('useSwipeGesture', () => {
|
||||
let touchStartHandler: EventListener | null;
|
||||
let touchMoveHandler: EventListener | null;
|
||||
let touchEndHandler: EventListener | null;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
touchStartHandler = null;
|
||||
touchMoveHandler = null;
|
||||
touchEndHandler = null;
|
||||
|
||||
// Capture the handlers when they're registered
|
||||
jest.spyOn(document, 'addEventListener').mockImplementation(
|
||||
(type: string, handler: EventListenerOrEventListenerObject, _options?: AddEventListenerOptions | boolean) => {
|
||||
if (type === 'touchstart') {
|
||||
touchStartHandler = handler as EventListener;
|
||||
} else if (type === 'touchmove') {
|
||||
touchMoveHandler = handler as EventListener;
|
||||
} else if (type === 'touchend') {
|
||||
touchEndHandler = handler as EventListener;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Default Options', () => {
|
||||
it('should return containerRef and swipeState', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture());
|
||||
expect(result.current.containerRef).toBeDefined();
|
||||
expect(result.current.containerRef.current).toBeNull();
|
||||
expect(result.current.swipeState).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have default edge size', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture());
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Start Detection', () => {
|
||||
it('should start swipe when touch begins near left edge', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(true);
|
||||
expect(result.current.swipeState.current.startX).toBe(20);
|
||||
});
|
||||
|
||||
it('should start swipe when touch begins near right edge', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 1000));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(true);
|
||||
});
|
||||
|
||||
it('should not start swipe when touch is in center', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 500));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start swipe when disabled', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture({ enabled: false }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch Move', () => {
|
||||
it('should detect left swipe direction', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
// Start near right edge (x > window.innerWidth - edgeSize = 974)
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 1000));
|
||||
});
|
||||
|
||||
// Move left: delta = 995 - 1000 = -5, but needs |delta| > 10 to set direction
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 980));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(true);
|
||||
expect(result.current.swipeState.current.direction).toBe('left');
|
||||
});
|
||||
|
||||
it('should detect right swipe direction', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
// Start near left edge (x < 50)
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
|
||||
// Move right: delta = 50 - 20 = 30 > 10
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 50));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.direction).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Touch End Callbacks', () => {
|
||||
it('should call onSwipeLeft when progress exceeds threshold', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
|
||||
|
||||
const onSwipeLeft = jest.fn();
|
||||
renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 }));
|
||||
|
||||
// Start near right edge (x > 375-50 = 325)
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 350));
|
||||
});
|
||||
|
||||
// Move left significantly (delta ≈ -95, progress ≈ 0.72 > 0.3)
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 255));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
expect(onSwipeLeft).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onSwipeRight when progress exceeds threshold', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
|
||||
|
||||
const onSwipeRight = jest.fn();
|
||||
renderHook(() => useSwipeGesture({ onSwipeRight, edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 150));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
expect(onSwipeRight).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trigger callbacks when progress is below threshold', () => {
|
||||
const onSwipeLeft = jest.fn();
|
||||
renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
|
||||
// Only move a tiny bit
|
||||
act(() => {
|
||||
touchMoveHandler!(mockTouchEvent('touchmove', 22));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
expect(onSwipeLeft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset swipe state after touch end', () => {
|
||||
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
|
||||
|
||||
act(() => {
|
||||
touchStartHandler!(mockTouchEvent('touchstart', 20));
|
||||
});
|
||||
|
||||
// End swipe without significant movement
|
||||
act(() => {
|
||||
touchEndHandler!(new Event('touchend'));
|
||||
});
|
||||
|
||||
expect(result.current.swipeState.current.isSwiping).toBe(false);
|
||||
expect(result.current.swipeState.current.progress).toBe(0);
|
||||
expect(result.current.swipeState.current.direction).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Lifecycle', () => {
|
||||
it('should add touch event listeners on mount', () => {
|
||||
// Temporarily restore native addEventListener for this test
|
||||
jest.restoreAllMocks();
|
||||
const addSpy = jest.spyOn(document, 'addEventListener');
|
||||
renderHook(() => useSwipeGesture());
|
||||
|
||||
expect(addSpy).toHaveBeenCalledWith('touchstart', expect.any(Function), { passive: true });
|
||||
expect(addSpy).toHaveBeenCalledWith('touchmove', expect.any(Function), { passive: false });
|
||||
expect(addSpy).toHaveBeenCalledWith('touchend', expect.any(Function), { passive: true });
|
||||
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should remove touch event listeners on unmount', () => {
|
||||
const removeSpy = jest.spyOn(document, 'removeEventListener');
|
||||
const { unmount } = renderHook(() => useSwipeGesture());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSpy).toHaveBeenCalledWith('touchstart', expect.any(Function));
|
||||
expect(removeSpy).toHaveBeenCalledWith('touchmove', expect.any(Function));
|
||||
expect(removeSpy).toHaveBeenCalledWith('touchend', expect.any(Function));
|
||||
|
||||
removeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user