9afdccdc14
- useLocalStorage: retorna tupla [valor, setter] tipada como [T, (v: T|fn) => void] - useAsync: espera microtask act cycle antes de checar status - useClipboard: mock navigator.clipboard.writeText antes - useMedia: mock matchMedia antes - Busca por padrão: act() + waitFor p/ efeitos assíncronos (sem fakeTimers gerais) - docs: PROJECTS-REGISTER, SESSION-STATE (pretérito + presente)
235 lines
9.0 KiB
TypeScript
235 lines
9.0 KiB
TypeScript
/**
|
|
* @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<string,string> = {};
|
|
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', () => {
|
|
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(0));
|
|
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(0));
|
|
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 });
|
|
});
|
|
});
|