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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user