test(unit): expand unit tests and fix stress test robustness

- Add comprehensive useFocusTrap tests (edge cases, disabled elements, empty containers)
- Add useSwipeGesture tests (haptic feedback, touch events, disabled state, effect deps)
- Enhance animations.test.tsx with Framer Motion mock and component tests
- Fix stress-test.js body.length undefined error when requests fail
- Fix home-content-v14.tsx for consistency
- Clean up generated performance test summary files
This commit is contained in:
2026-07-31 20:25:26 +08:00
parent 3e629bd9ee
commit 41b73063cd
7 changed files with 613 additions and 549 deletions
+5 -13
View File
@@ -106,29 +106,21 @@ function HeroSection({ heroData, stats }: {
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-32 md:py-40 lg:py-48">
<div className="max-w-4xl">
{/* 品牌标识:Logo + 公司名 */}
{/* 品牌标识:Logo 本身已包含印章 + 公司名 + NOVALON */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0, ease: EASE_OUT }}
className="flex items-center gap-3 mb-8"
className="mb-8"
>
<Image
src="/logo.svg"
alt={`${COMPANY_INFO.name} Logo`}
width={192}
height={48}
className="h-10 w-auto sm:h-12"
width={224}
height={56}
className="h-12 w-auto sm:h-14"
priority
/>
<div className="flex flex-col">
<span className="text-sm sm:text-base font-semibold text-ink leading-tight">
{COMPANY_INFO.name}
</span>
<span className="text-[10px] sm:text-xs tracking-[0.2em] text-text-muted uppercase">
NOVALON
</span>
</div>
</motion.div>
{/* 主标题:大胆、直接 */}
+123
View File
@@ -6,6 +6,7 @@ import { useFocusTrap } from './use-focus-trap';
describe('useFocusTrap', () => {
beforeEach(() => {
jest.clearAllMocks();
document.body.style.overflow = 'unset';
});
describe('Initial State', () => {
@@ -18,8 +19,14 @@ describe('useFocusTrap', () => {
describe('When Active', () => {
it('should store previous active element', () => {
const activeElement = document.createElement('button');
document.body.appendChild(activeElement);
activeElement.focus();
const { result } = renderHook(() => useFocusTrap<HTMLDivElement>(true));
expect(result.current).toBeDefined();
document.body.removeChild(activeElement);
});
it('should add keydown event listener', () => {
@@ -45,6 +52,34 @@ describe('useFocusTrap', () => {
renderHook(() => useFocusTrap<HTMLDivElement>(false));
expect(document.body.style.overflow).toBe('unset');
});
it('should recreate handleKeyDown when isActive changes', () => {
const addSpy = jest.spyOn(document, 'addEventListener');
const { rerender } = renderHook(
({ isActive }) => useFocusTrap<HTMLDivElement>(isActive),
{ initialProps: { isActive: false } }
);
// No handlers registered when inactive
let keydownHandlers = addSpy.mock.calls.filter(c => c[0] === 'keydown');
expect(keydownHandlers.length).toBe(0);
// Activate: handler is registered
rerender({ isActive: true });
keydownHandlers = addSpy.mock.calls.filter(c => c[0] === 'keydown');
expect(keydownHandlers.length).toBe(1);
// Deactivate and reactivate: new handler should be created
rerender({ isActive: false });
rerender({ isActive: true });
keydownHandlers = addSpy.mock.calls.filter(c => c[0] === 'keydown');
// With real deps, a new handler is created (2nd addEventListener call)
// With [] deps, it calls addEventListener 2 times (remove + add = no-op)
// but the actual addEventListener still gets called
expect(keydownHandlers.length).toBeGreaterThanOrEqual(2);
});
});
describe('Cleanup', () => {
@@ -60,6 +95,18 @@ describe('useFocusTrap', () => {
unmount();
expect(document.body.style.overflow).toBe('unset');
});
it('should clean up event listener when deactivating', () => {
const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
const { rerender } = renderHook(
({ isActive }) => useFocusTrap<HTMLDivElement>(isActive),
{ initialProps: { isActive: true } }
);
rerender({ isActive: false });
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
});
});
describe('Tab Navigation', () => {
@@ -91,6 +138,16 @@ describe('useFocusTrap', () => {
expect(result.current).toBeDefined();
});
it('should not crash when Escape is pressed and no previous element exists', () => {
// Render with isActive=false so no event listener is registered
renderHook(() => useFocusTrap<HTMLDivElement>(false));
// Dispatch Escape - should not crash since no handler is registered
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
}).not.toThrow();
});
});
describe('State Changes', () => {
@@ -158,5 +215,71 @@ describe('useFocusTrap', () => {
expect(previous).toHaveFocus();
document.body.removeChild(previous);
});
it('should prevent default on Escape key', () => {
render(<TrapComponent isActive />);
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true });
const escapePreventDefaultSpy = jest.spyOn(escapeEvent, 'preventDefault');
document.dispatchEvent(escapeEvent);
expect(escapePreventDefaultSpy).toHaveBeenCalled();
});
});
describe('Edge Cases', () => {
it('should handle empty container with no focusable elements', () => {
function EmptyContainer() {
const ref = useFocusTrap<HTMLDivElement>(true);
return <div ref={ref} data-testid="empty" />;
}
render(<EmptyContainer />);
// Dispatch Tab - should not crash
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
}).not.toThrow();
});
it('should handle disabled focusable elements', () => {
function ContainerWithDisabled() {
const ref = useFocusTrap<HTMLDivElement>(true);
return (
<div ref={ref}>
<button disabled>disabled</button>
<button>enabled</button>
</div>
);
}
render(<ContainerWithDisabled />);
const buttons = screen.getAllByRole('button');
// Only the enabled button is focusable
buttons[1]!.focus();
// Tab from last enabled element should cycle to first enabled element
fireEvent.keyDown(buttons[1]!, { key: 'Tab' });
expect(buttons[1]).toHaveFocus();
});
it('should handle hidden focusable elements', () => {
function ContainerWithHidden() {
const ref = useFocusTrap<HTMLDivElement>(true);
return (
<div ref={ref}>
<button style={{ display: 'none' }}>hidden</button>
<button>visible</button>
</div>
);
}
render(<ContainerWithHidden />);
// Only the visible button should be focusable
expect(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
}).not.toThrow();
});
});
});
+174
View File
@@ -9,6 +9,14 @@ jest.mock('next/navigation', () => ({
}),
}));
// Mock navigator.vibrate
const mockVibrate = jest.fn();
Object.defineProperty(navigator, 'vibrate', {
value: mockVibrate,
configurable: true,
writable: true,
});
/**
* Create a plain Event-like object with touches array,
* since jsdom's TouchEvent doesn't support the touches property properly.
@@ -255,5 +263,171 @@ describe('useSwipeGesture', () => {
removeSpy.mockRestore();
});
it('should recreate event handlers when options change', () => {
const addSpy = jest.spyOn(document, 'addEventListener');
const { rerender } = renderHook(
({ enabled }) => useSwipeGesture({ enabled }),
{ initialProps: { enabled: true } }
);
// Count initial keydown handlers
const initialHandlers = addSpy.mock.calls.filter(c => c[0] === 'touchstart').length;
// Rerender with same options - should not add new handlers
rerender({ enabled: true });
const afterSameRerender = addSpy.mock.calls.filter(c => c[0] === 'touchstart').length;
expect(afterSameRerender).toBe(initialHandlers);
addSpy.mockRestore();
});
});
describe('Haptic Feedback', () => {
it('should call navigator.vibrate on successful swipe left', () => {
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
const onSwipeLeft = jest.fn();
renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 }));
act(() => {
touchStartHandler!(mockTouchEvent('touchstart', 350));
});
act(() => {
touchMoveHandler!(mockTouchEvent('touchmove', 255));
});
act(() => {
touchEndHandler!(new Event('touchend'));
});
// light haptic = 10ms
expect(mockVibrate).toHaveBeenCalledWith(10);
expect(onSwipeLeft).toHaveBeenCalled();
});
it('should call navigator.vibrate on successful swipe right', () => {
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(mockVibrate).toHaveBeenCalledWith(10);
expect(onSwipeRight).toHaveBeenCalled();
});
it('should not call vibrate when progress is below threshold', () => {
renderHook(() => useSwipeGesture({ edgeSize: 50 }));
act(() => {
touchStartHandler!(mockTouchEvent('touchstart', 20));
});
// Only move a tiny bit - below threshold
act(() => {
touchMoveHandler!(mockTouchEvent('touchmove', 22));
});
act(() => {
touchEndHandler!(new Event('touchend'));
});
expect(mockVibrate).not.toHaveBeenCalled();
});
it('should not throw when navigator.vibrate is not available', () => {
// Temporarily remove vibrate from navigator entirely
delete (navigator as any).vibrate;
Object.defineProperty(window, 'innerWidth', { value: 375, configurable: true });
const onSwipeLeft = jest.fn();
renderHook(() => useSwipeGesture({ onSwipeLeft, edgeSize: 50 }));
act(() => {
touchStartHandler!(mockTouchEvent('touchstart', 350));
});
act(() => {
touchMoveHandler!(mockTouchEvent('touchmove', 255));
});
expect(() => {
act(() => {
touchEndHandler!(new Event('touchend'));
});
}).not.toThrow();
expect(onSwipeLeft).toHaveBeenCalled();
// Restore
Object.defineProperty(navigator, 'vibrate', {
value: mockVibrate,
configurable: true,
writable: true,
});
});
});
describe('Edge Cases', () => {
it('should handle touch start without touches property', () => {
renderHook(() => useSwipeGesture({ edgeSize: 50 }));
const eventWithoutTouches = { type: 'touchstart' } as Event;
expect(() => {
touchStartHandler!(eventWithoutTouches);
}).not.toThrow();
});
it('should handle touch move without being in swiping state', () => {
renderHook(() => useSwipeGesture({ edgeSize: 50 }));
// Dispatch touchmove without touchstart first
expect(() => {
touchMoveHandler!(mockTouchEvent('touchmove', 100));
}).not.toThrow();
});
it('should handle touch end without being in swiping state', () => {
renderHook(() => useSwipeGesture({ edgeSize: 50 }));
expect(() => {
touchEndHandler!(new Event('touchend'));
}).not.toThrow();
});
it('should handle touch move without touches after swipe started', () => {
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
renderHook(() => useSwipeGesture({ edgeSize: 50 }));
// Start swipe near right edge
act(() => {
touchStartHandler!(mockTouchEvent('touchstart', 1000));
});
// Move without touches
const moveWithoutTouches = { type: 'touchmove', preventDefault: jest.fn() } as unknown as Event;
expect(() => {
touchMoveHandler!(moveWithoutTouches);
}).not.toThrow();
});
it('should handle touch start on exact edge boundary', () => {
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
const { result } = renderHook(() => useSwipeGesture({ edgeSize: 50 }));
// Touch at the inner edge boundary (x = 49, which is < 50)
act(() => {
touchStartHandler!(mockTouchEvent('touchstart', 49));
});
expect(result.current.swipeState.current.isSwiping).toBe(true);
});
});
});
+237 -5
View File
@@ -4,7 +4,7 @@ import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, variants, className, whileHover, whileTap, ...props }: any) => (
div: ({ children, initial, animate, variants, className, whileHover, whileTap, transition, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
@@ -12,6 +12,7 @@ jest.mock('framer-motion', () => ({
data-variants={JSON.stringify(variants)}
data-while-hover={JSON.stringify(whileHover)}
data-while-tap={JSON.stringify(whileTap)}
data-transition={JSON.stringify(transition)}
className={className}
{...props}
>
@@ -30,11 +31,13 @@ jest.mock('framer-motion', () => ({
{children}
</button>
),
span: ({ children, className, animate, ...props }: any) => (
span: ({ children, className, animate, initial, transition, ...props }: any) => (
<span
data-testid="motion-span"
className={className}
data-animate={JSON.stringify(animate)}
data-initial={JSON.stringify(initial)}
data-transition={JSON.stringify(transition)}
{...props}
>
{children}
@@ -51,10 +54,36 @@ jest.mock('framer-motion', () => ({
path: ({ variants, ...props }: any) => (
<path data-testid="motion-path" data-variants={JSON.stringify(variants)} {...props} />
),
a: ({ children, className, ...props }: any) => (
<a data-testid="motion-a" className={className} {...props}>
{children}
</a>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>
{children}
</h1>
),
h2: ({ children, className, ...props }: any) => (
<h2 data-testid="motion-h2" className={className} {...props}>
{children}
</h2>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>
{children}
</p>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: jest.fn(() => true),
useSpring: jest.fn((value) => value),
useTransform: jest.fn((value) => value),
useMotionValue: jest.fn((initial) => ({
get: () => initial,
set: jest.fn(),
onChange: jest.fn(),
})),
}));
describe('Animation Variants', () => {
@@ -533,19 +562,222 @@ describe('Animation Components', () => {
it('should accept bounce effect', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} effect="bounce" />);
expect(screen.getByTestId('motion-span')).toBeInTheDocument();
const element = screen.getByTestId('motion-span');
expect(element).toBeInTheDocument();
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
expect(animate).toEqual({ y: [0, -10, 0] });
});
it('should accept slide effect', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} effect="slide" />);
expect(screen.getByTestId('motion-span')).toBeInTheDocument();
const element = screen.getByTestId('motion-span');
expect(element).toBeInTheDocument();
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
expect(animate).toEqual({ y: 0, opacity: 1 });
});
it('should accept flip effect', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={100} effect="flip" />);
expect(screen.getByTestId('motion-span')).toBeInTheDocument();
const element = screen.getByTestId('motion-span');
expect(element).toBeInTheDocument();
const animate = JSON.parse(element.getAttribute('data-animate') || '{}');
expect(animate).toEqual({ rotateX: 0 });
});
it('should render initial count value', async () => {
const { CounterWithEffect } = await import('./animations');
render(<CounterWithEffect end={42} />);
// Component renders with end value, then useEffect resets to 0
// due to useInView mock returning true
const element = screen.getByTestId('motion-span');
expect(element).toBeInTheDocument();
expect(element.textContent).toBe('0');
});
});
});
describe('Enhanced Component Tests', () => {
describe('CountUp', () => {
it('should render with custom duration', async () => {
const { CountUp } = await import('./animations');
render(<CountUp end={1000} duration={3000} />);
const element = screen.getByTestId('motion-span');
expect(element).toBeInTheDocument();
});
it('should render with prefix and suffix', async () => {
const { CountUp } = await import('./animations');
render(<CountUp end={100} prefix="$" suffix="%" />);
expect(screen.getByText(/\$/)).toBeInTheDocument();
expect(screen.getByText(/%/)).toBeInTheDocument();
});
});
describe('RippleButton', () => {
it('should create ripple on click', async () => {
const { RippleButton } = await import('./animations');
render(<RippleButton>Click Me</RippleButton>);
const button = screen.getByTestId('motion-button');
fireEvent.click(button);
// Ripple creates a motion.span
expect(screen.getByTestId('motion-button')).toBeInTheDocument();
});
it('should handle multiple clicks', async () => {
const { RippleButton } = await import('./animations');
const handleClick = jest.fn();
render(<RippleButton onClick={handleClick}>Click Me</RippleButton>);
const button = screen.getByTestId('motion-button');
fireEvent.click(button);
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(2);
});
it('should apply custom rippleColor', async () => {
const { RippleButton } = await import('./animations');
render(<RippleButton rippleColor="rgba(0, 0, 255, 0.5)">Click</RippleButton>);
const button = screen.getByTestId('motion-button');
fireEvent.click(button);
expect(screen.getByText('Click')).toBeInTheDocument();
});
});
describe('RotatingBorder', () => {
it('should render with custom borderWidth', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder borderWidth={4}>Test</RotatingBorder>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('should render with custom duration', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder duration={8}>Test</RotatingBorder>);
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}');
expect(animate).toEqual({ rotate: 360 });
});
it('should have infinite repeat transition', async () => {
const { RotatingBorder } = await import('./animations');
render(<RotatingBorder duration={4}>Test</RotatingBorder>);
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
const transitionAttr = motionDiv.getAttribute('data-transition') || '';
// JSON.stringify(Infinity) returns null, so check string representation
expect(transitionAttr).toContain('"repeat"');
expect(transitionAttr).toContain('"duration"');
expect(transitionAttr).toContain('4');
});
});
describe('SplitText', () => {
it('should render with custom delay', async () => {
const { SplitText } = await import('./animations');
render(<SplitText text="ABC" delay={0.5} />);
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByText('C')).toBeInTheDocument();
});
});
describe('WaveText', () => {
it('should render with custom delay', async () => {
const { WaveText } = await import('./animations');
render(<WaveText text="ABC" delay={0.5} />);
const spans = screen.getAllByTestId('motion-span');
expect(spans.length).toBeGreaterThanOrEqual(3);
});
});
describe('Typewriter', () => {
it('should render with custom speed', async () => {
const { Typewriter } = await import('./animations');
render(<Typewriter text="Hello" speed={100} />);
const cursor = screen.getByText('|');
expect(cursor).toBeInTheDocument();
});
});
describe('GradientText', () => {
it('should render with custom colors', async () => {
const { GradientText } = await import('./animations');
render(<GradientText colors={['#ff0000', '#00ff00', '#0000ff']}>Test</GradientText>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('should render with animation duration', async () => {
const { GradientText } = await import('./animations');
render(<GradientText duration={5}>Test</GradientText>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
describe('ShimmerButton', () => {
it('should render with animation', async () => {
const { ShimmerButton } = await import('./animations');
render(<ShimmerButton>Shimmer</ShimmerButton>);
const motionDiv = screen.getAllByTestId('motion-div')[0]!;
const animate = JSON.parse(motionDiv.getAttribute('data-animate') || '{}');
expect(animate).toHaveProperty('backgroundPosition');
});
});
describe('MagneticButton', () => {
it('should render with custom strength', async () => {
const { MagneticButton } = await import('./animations');
render(<MagneticButton strength={0.5}>Test</MagneticButton>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
it('should handle click events', async () => {
const { MagneticButton } = await import('./animations');
const handleClick = jest.fn();
render(<MagneticButton onClick={handleClick}>Click</MagneticButton>);
const element = screen.getByText('Click');
fireEvent.click(element);
expect(handleClick).toHaveBeenCalled();
});
});
describe('BlurReveal', () => {
it('should render with custom delay', async () => {
const { BlurReveal } = await import('./animations');
render(<BlurReveal delay={0.5}>Test</BlurReveal>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
describe('GlitchText', () => {
it('should render text correctly', async () => {
const { GlitchText } = await import('./animations');
render(<GlitchText text="Test" />);
const elements = screen.getAllByText('Test');
expect(elements.length).toBeGreaterThan(0);
});
});
describe('FloatingElement', () => {
it('should render with custom duration', async () => {
const { FloatingElement } = await import('./animations');
render(<FloatingElement duration={5}>Test</FloatingElement>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
describe('PulseElement', () => {
it('should render with custom duration', async () => {
const { PulseElement } = await import('./animations');
render(<PulseElement duration={3}>Test</PulseElement>);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
});