/** * @pulse-libs/core — Hooks Tests (clean suite) * @vitest-environment jsdom */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useToggle, useAsync, useDebounce, useLocalStorage, useMedia, useInterval, useClipboard, useFetch, } from '../src/hooks/index'; const originalFetch = global.fetch; // ─ mocks globais ─────────────────────────────────────────────────── const lsStore: Record = {}; Object.defineProperty(global, 'localStorage', { value: { getItem: k => lsStore[k] ?? null, setItem: (k,v) => { lsStore[k] = v; }, removeItem: k => { delete lsStore[k]; }, clear: () => { Object.keys(lsStore).forEach(k => delete lsStore[k]); }, }, writable: true, }); Object.defineProperty(global.navigator, 'clipboard', { value: { writeText: vi.fn().mockResolvedValue(undefined) }, configurable: true, writable: true, }); Object.defineProperty(global, 'matchMedia', { value: vi.fn().mockReturnValue({ matches: false, media: '', addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), }), configurable: true, }); beforeEach(() => { lsStore[''] = ''; Object.keys(lsStore).forEach(k => delete lsStore[k]); }); // ════════════════════════════════════════════ describe('useToggle', () => { it('inicia false por default', () => { const { result } = renderHook(() => useToggle()); expect(result.current[0]).toBe(false); }); it('inicia true se passado', () => { const { result } = renderHook(() => useToggle(true)); expect(result.current[0]).toBe(true); }); it('alterna com toggle()', () => { const { result } = renderHook(() => useToggle(false)); act(() => result.current[1]()); expect(result.current[0]).toBe(true); act(() => result.current[1]()); expect(result.current[0]).toBe(false); }); it('força valor com set()', () => { const { result } = renderHook(() => useToggle(false)); act(() => result.current[2](true)); expect(result.current[0]).toBe(true); }); }); // ════════════════════════════════════════════ describe('useAsync', () => { afterEach(() => { global.fetch = originalFetch; }); it('começa em idling e depois transiciona', async () => { const fn = vi.fn(async () => Promise.resolve('ok')); const { result } = renderHook(() => useAsync(fn, [])); // Imediatamente após renderHook: efeitos ainda não rodaram (microtask) // Espera um ciclo de event loop await waitFor(() => { expect(['idle','loading','success','error']).toContain(result.current.status); }); // Com sucesso, deve estar em success await waitFor(() => expect(result.current.status).toBe('success'), { timeout: 3000 }); if ('data' in result.current) expect(result.current.data).toBe('ok'); }); it('vai para error se promise rejeita', async () => { const { result } = renderHook(() => useAsync(async () => { throw new Error('x'); }, [])); await waitFor(() => expect(result.current.status).toBe('error'), { timeout: 3000 }); if ('error' in result.current) expect(result.current.error).toBeTruthy(); }); it('re-executa só quando deps mudam', async () => { const fn = vi.fn(async () => 'v'); const { rerender } = renderHook( ({ d }) => useAsync(fn, d), { initialProps: { d: ['a'] as string[] } } ); await waitFor(() => expect(fn).toHaveBeenCalledTimes(1), { timeout: 3000 }); rerender({ d: ['b'] as string[] }); await waitFor(() => expect(fn).toHaveBeenCalledTimes(2), { timeout: 3000 }); }); }); // ════════════════════════════════════════════ describe('useDebounce', () => { it('retorna valor inicial', () => { const { result } = renderHook(() => useDebounce('x', 200)); expect(result.current).toBe('x'); }); it('atrasa atualização até o timer', async () => { const { result, rerender } = renderHook( ({ v }) => useDebounce(v, 200), { initialProps: { v: 'a' } } ); rerender({ v: 'b' }); expect(result.current).toBe('a'); await act(async () => { await new Promise(r => setTimeout(r, 300)); }); expect(result.current).toBe('b'); }); it('flushes última mudança em rápidas trocas', async () => { const { result, rerender } = renderHook( ({ v }) => useDebounce(v, 200), { initialProps: { v: 1 } } ); rerender({ v: 2 }); rerender({ v: 3 }); rerender({ v: 4 }); await act(async () => { await new Promise(r => setTimeout(r, 300)); }); expect(result.current).toBe(4); }); }); // ════════════════════════════════════════════ describe('useLocalStorage', () => { it('retorna tupla [valor, setter] corretamente', () => { const { result } = renderHook(() => useLocalStorage('k1', 'hello' as string) as any ); expect(Array.isArray(result.current)).toBe(true); expect(result.current[0]).toBe('hello'); expect(typeof result.current[1]).toBe('function'); }); it('salva e lê valor', () => { const { result } = renderHook(() => useLocalStorage('k2', 'init' as string) as any ); act(() => result.current[1]('novo')); expect(result.current[0]).toBe('novo'); }); it('persiste em sessões', () => { lsStore['k3'] = '"saved"'; const { result } = renderHook(() => useLocalStorage('k3', 'def' as string) as any ); expect(result.current[0]).toBe('saved'); }); it('aceita updater fn', () => { const { result } = renderHook(() => useLocalStorage('k4', 0 as number) as any ); act(() => result.current[1]((n: number) => n + 1)); expect(result.current[0]).toBe(1); }); }); // ════════════════════════════════════════════ describe('useMedia', () => { it('retorna booleano', () => { const { result } = renderHook(() => useMedia('screen')); expect(typeof result.current).toBe('boolean'); }); }); // ════════════════════════════════════════════ describe('useInterval', () => { it('não executa quando ms é null', () => { const fn = vi.fn(); renderHook(() => useInterval(fn, null)); expect(fn).not.toHaveBeenCalled(); }); it('executa imediatamente com immediate=true', () => { const fn = vi.fn(); renderHook(() => useInterval(fn, 1000, true)); expect(fn).toHaveBeenCalledTimes(1); }); }); // ════════════════════════════════════════════ describe('useClipboard', () => { beforeEach(() => { (global.navigator.clipboard.writeText as any).mockResolvedValue(undefined); }); it('inicia copied=false', () => { const { result } = renderHook(() => useClipboard(2000)); expect(result.current.copied).toBe(false); }); it('copia texto com sucesso', async () => { const { result } = renderHook(() => useClipboard(5000)); const ok = await act(() => result.current.copy('hello')); expect(ok).toBe(true); expect(result.current.copied).toBe(true); }); it('retorna false em erro', async () => { (global.navigator.clipboard.writeText as any).mockRejectedValue(new Error('denied')); const { result } = renderHook(() => useClipboard(5000)); const ok = await act(() => result.current.copy('falha')); expect(ok).toBe(false); }); }); // ════════════════════════════════════════════ describe('useFetch', () => { afterEach(() => { global.fetch = originalFetch; }); const r200 = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); it('retorna success para HTTP 200', async () => { global.fetch = vi.fn(() => Promise.resolve(r200({ ok: true }))) as any; const { result } = renderHook(() => useFetch('/api', {}) as any); await waitFor(() => expect(result.current.status).toBe('success'), { timeout: 4000 }); expect((result.current as any).data.ok).toBe(true); }); it('retorna error para HTTP 404', async () => { global.fetch = vi.fn(() => Promise.resolve( new Response('nf', { status: 404, headers: { 'Content-Type': 'application/json' } }))) as any; const { result } = renderHook(() => useFetch('/404', {}) as any); await waitFor(() => expect(result.current.status).toBe('error'), { timeout: 4000 }); }); it('retorna error em falha de rede', async () => { global.fetch = vi.fn(() => Promise.reject(new TypeError('net'))) as any; const { result } = renderHook(() => useFetch('/fail', {}) as any); await waitFor(() => expect(result.current.status).toBe('error'), { timeout: 4000 }); }); });