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:
张翔
2026-07-07 19:42:50 +08:00
parent 55381d7012
commit 38be4a19ef
15 changed files with 2907 additions and 6 deletions
+147
View File
@@ -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();
});
});
});
+1 -1
View File
@@ -48,7 +48,7 @@ export function useCountUp({
if (progress < 1) {
frameRef.current = requestAnimationFrame(animate);
} else {
setValue(end);
setValue(Number(end.toFixed(decimals)));
}
};
+202
View File
@@ -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();
});
});
});
+125
View File
@@ -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);
});
});
});
+259
View File
@@ -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();
});
});
});