diff --git a/src/__tests__/useClaims.test.tsx b/src/__tests__/useClaims.test.tsx new file mode 100644 index 0000000..aa99dc6 --- /dev/null +++ b/src/__tests__/useClaims.test.tsx @@ -0,0 +1,84 @@ +import { act } from 'react'; +import { useClaims } from '../hooks/useClaims'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { Claim } from '../types'; + +const { fetchUserClaims } = vi.hoisted(() => ({ + fetchUserClaims: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ fetchUserClaims })); + +function makeClaim(overrides: Partial = {}): Claim { + return { + id: 'claim-1', + policyId: 'policy-1', + claimant: 'GABCDEF1234567890', + triggerMet: true, + status: 'Pending', + submittedAt: 1_720_000_000, + processedAt: null, + ...overrides, + }; +} + +describe('useClaims', () => { + beforeEach(() => { + fetchUserClaims.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not poll again while paused', async () => { + fetchUserClaims.mockResolvedValue([makeClaim()]); + + const hook = renderHook(() => useClaims('GWALLET')); + await flushMicrotasks(); + expect(fetchUserClaims).toHaveBeenCalledTimes(1); + + act(() => hook.current.togglePause()); + expect(hook.current.paused).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + // Still only the initial call -- no poll fired while paused. + expect(fetchUserClaims).toHaveBeenCalledTimes(1); + }); + + it('resumes polling after togglePause is called again', async () => { + fetchUserClaims.mockResolvedValue([makeClaim()]); + + const hook = renderHook(() => useClaims('GWALLET')); + await flushMicrotasks(); + + act(() => hook.current.togglePause()); + act(() => hook.current.togglePause()); + expect(hook.current.paused).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + expect(fetchUserClaims).toHaveBeenCalledTimes(2); + }); + + it('counts down secondsUntilRefresh toward zero after a successful load', async () => { + fetchUserClaims.mockResolvedValue([makeClaim()]); + + const hook = renderHook(() => useClaims('GWALLET')); + await flushMicrotasks(); + + expect(hook.current.secondsUntilRefresh).toBe(15); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + expect(hook.current.secondsUntilRefresh).toBe(10); + }); +}); diff --git a/src/__tests__/useKeyboardShortcut.test.tsx b/src/__tests__/useKeyboardShortcut.test.tsx index 14f82bf..4c742cf 100644 --- a/src/__tests__/useKeyboardShortcut.test.tsx +++ b/src/__tests__/useKeyboardShortcut.test.tsx @@ -63,6 +63,31 @@ describe('useKeyboardShortcut', () => { expect(secondHandler).toHaveBeenCalledTimes(1); }); + it('does not re-register the keydown listener on re-render when modifiers is omitted', () => { + // Regression test: `modifiers = {}` is a new object reference every + // render, so a naive [key, modifiers] dependency array tears down and + // re-registers the listener every render cycle. + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + const handler = vi.fn(); + + const hook = renderHook(() => useKeyboardShortcut('k', handler)); + const initialAddCalls = addSpy.mock.calls.filter((c) => c[0] === 'keydown').length; + + hook.rerender(); + hook.rerender(); + hook.rerender(); + + const addCallsAfterRerenders = addSpy.mock.calls.filter((c) => c[0] === 'keydown').length; + const removeCallsAfterRerenders = removeSpy.mock.calls.filter((c) => c[0] === 'keydown').length; + + expect(addCallsAfterRerenders).toBe(initialAddCalls); + expect(removeCallsAfterRerenders).toBe(0); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + it('removes the listener on unmount', () => { const handler = vi.fn(); const hook = renderHook(() => useKeyboardShortcut('k', handler)); diff --git a/src/app/claims/page.tsx b/src/app/claims/page.tsx index 7afcbc4..d20833a 100644 --- a/src/app/claims/page.tsx +++ b/src/app/claims/page.tsx @@ -12,7 +12,16 @@ import { downloadClaimsCSV, downloadClaimsJSON } from '@/lib/claimsExport'; export default function ClaimsPage() { const { address, connected } = useWallet(); - const { claims, loading, error, refetch } = useClaims(address); + const { + claims, + loading, + error, + refetch, + paused, + togglePause, + secondsUntilRefresh, + secondsSinceRefresh, + } = useClaims(address); const [refreshing, setRefreshing] = useState(false); const [exportOpen, setExportOpen] = useState(false); @@ -77,6 +86,13 @@ export default function ClaimsPage() { )} )} +