WellNuo/hooks/__tests__/useDebounce.test.ts
Sergei 7feca4d54b Add debouncing for refresh buttons to prevent duplicate API calls
Implemented a reusable useDebounce hook to prevent rapid-fire clicks
on refresh buttons throughout the application.

Changes:
- Created hooks/useDebounce.ts with configurable delay and leading/trailing edge options
- Added comprehensive unit tests in hooks/__tests__/useDebounce.test.ts
- Applied debouncing to dashboard WebView refresh button (app/(tabs)/dashboard.tsx)
- Applied debouncing to beneficiary detail pull-to-refresh (app/(tabs)/beneficiaries/[id]/index.tsx)
- Applied debouncing to equipment screen refresh (app/(tabs)/beneficiaries/[id]/equipment.tsx)
- Applied debouncing to all error retry buttons (components/ui/ErrorMessage.tsx)
- Fixed jest.setup.js to properly mock React Native modules
- Added implementation documentation in docs/DEBOUNCE_IMPLEMENTATION.md

Technical details:
- Default 1-second debounce delay
- Leading edge execution (immediate first call, then debounce)
- Type-safe with TypeScript generics
- Automatic cleanup on component unmount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 11:44:16 -08:00

236 lines
6.5 KiB
TypeScript

import { renderHook, act } from '@testing-library/react-native';
import { useDebounce } from '../useDebounce';
jest.useFakeTimers();
describe('useDebounce', () => {
beforeEach(() => {
jest.clearAllTimers();
});
describe('leading edge (default)', () => {
it('should execute callback immediately on first call', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
act(() => {
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should ignore subsequent calls within delay period', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
// First call - executes immediately
act(() => {
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(1);
// Second call within delay - ignored
act(() => {
jest.advanceTimersByTime(500);
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(1);
// Third call within delay - ignored
act(() => {
jest.advanceTimersByTime(400);
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should allow execution after delay period has passed', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
// First call
act(() => {
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(1);
// Wait for delay to pass
act(() => {
jest.advanceTimersByTime(1000);
});
// Second call after delay - executes
act(() => {
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(2);
});
it('should pass arguments to callback', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
act(() => {
result.current.debouncedFn('test', 123, { key: 'value' });
});
expect(callback).toHaveBeenCalledWith('test', 123, { key: 'value' });
});
});
describe('trailing edge', () => {
it('should delay execution on trailing edge', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000, leading: false }));
act(() => {
result.current.debouncedFn();
});
// Should not execute immediately
expect(callback).not.toHaveBeenCalled();
// Should execute after delay
act(() => {
jest.advanceTimersByTime(1000);
});
expect(callback).toHaveBeenCalledTimes(1);
});
it('should reset timer on subsequent calls', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000, leading: false }));
// First call
act(() => {
result.current.debouncedFn();
});
// Second call before timer fires - resets timer
act(() => {
jest.advanceTimersByTime(500);
result.current.debouncedFn();
});
// Advance to original timeout - should not fire
act(() => {
jest.advanceTimersByTime(500);
});
expect(callback).not.toHaveBeenCalled();
// Advance to new timeout - should fire once
act(() => {
jest.advanceTimersByTime(500);
});
expect(callback).toHaveBeenCalledTimes(1);
});
});
describe('isDebouncing', () => {
it('should return true within delay period', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
act(() => {
result.current.debouncedFn();
});
expect(result.current.isDebouncing()).toBe(true);
act(() => {
jest.advanceTimersByTime(500);
});
expect(result.current.isDebouncing()).toBe(true);
});
it('should return false after delay period', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
act(() => {
result.current.debouncedFn();
});
act(() => {
jest.advanceTimersByTime(1000);
});
expect(result.current.isDebouncing()).toBe(false);
});
});
describe('cancel', () => {
it('should cancel pending trailing edge execution', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000, leading: false }));
act(() => {
result.current.debouncedFn();
});
// Cancel before timer fires
act(() => {
jest.advanceTimersByTime(500);
result.current.cancel();
});
// Advance past original timeout
act(() => {
jest.advanceTimersByTime(600);
});
expect(callback).not.toHaveBeenCalled();
});
});
describe('custom delay', () => {
it('should respect custom delay value', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 2000 }));
act(() => {
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(1);
// Call within 2 seconds - ignored
act(() => {
jest.advanceTimersByTime(1500);
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(1);
// Call after 2 seconds - executes
act(() => {
jest.advanceTimersByTime(500);
result.current.debouncedFn();
});
expect(callback).toHaveBeenCalledTimes(2);
});
});
describe('rapid clicks simulation', () => {
it('should handle rapid refresh button clicks correctly', () => {
const callback = jest.fn();
const { result } = renderHook(() => useDebounce(callback, { delay: 1000 }));
// Simulate 5 rapid clicks within 2 seconds
act(() => {
result.current.debouncedFn(); // Click 1 - executes
jest.advanceTimersByTime(200);
result.current.debouncedFn(); // Click 2 - ignored
jest.advanceTimersByTime(300);
result.current.debouncedFn(); // Click 3 - ignored
jest.advanceTimersByTime(400);
result.current.debouncedFn(); // Click 4 - ignored
jest.advanceTimersByTime(400);
result.current.debouncedFn(); // Click 5 - executes (>1000ms since last execution)
});
// Only first and last should execute
expect(callback).toHaveBeenCalledTimes(2);
});
});
});