test(components): 56/56 Button, Input, Alert, Card — clean suite no jest-dom (pure DOM matchers)
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* @pulse-libs/core — Component Tests (clean suite)
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Acessa elementos exclusivamente via render().container.querySelector/querySelectorAll
|
||||
* para evitar conflitos com o accessibility tree vazio do jsdom.
|
||||
*/
|
||||
import { vi, beforeEach } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
Button, Input, Alert, Card, CardHeader, CardTitle, CardBody,
|
||||
} from '../src/components/index';
|
||||
|
||||
// ─ mocks globais ────────────────────────────────────────────────
|
||||
beforeEach(() => {});
|
||||
Object.defineProperty(global, 'matchMedia', {
|
||||
value: vi.fn().mockReturnValue({
|
||||
matches: false, media: '',
|
||||
addEventListener: vi.fn(), removeEventListener: vi.fn(),
|
||||
}),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Button
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
describe('Button', () => {
|
||||
it('renderiza <button> e está habilitado', () => {
|
||||
const b = render(<Button>Clique</Button>).container.querySelector('button')!;
|
||||
expect(b.textContent).toBe('Clique');
|
||||
expect(b.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('é <button type="button"> por default', () => {
|
||||
expect(render(<Button />).container.querySelector('button')!.tagName.toLowerCase()).toBe('button');
|
||||
});
|
||||
|
||||
it('variant primary → bg-indigo-600', () => {
|
||||
expect(render(<Button />).container.querySelector('button')!.classList.contains('bg-indigo-600')).toBe(true);
|
||||
});
|
||||
|
||||
it('variant danger → bg-red-600', () => {
|
||||
expect(render(<Button variant="danger" />).container.querySelector('button')!.classList.contains('bg-red-600')).toBe(true);
|
||||
});
|
||||
|
||||
it('variant secondary → border + bg-white', () => {
|
||||
const b = render(<Button variant="secondary" />).container.querySelector('button')!;
|
||||
expect(b.classList.contains('border')).toBe(true);
|
||||
expect(b.classList.contains('bg-white')).toBe(true);
|
||||
});
|
||||
|
||||
it('variant ghost → text-gray-600 + hover sem bg', () => {
|
||||
const b = render(<Button variant="ghost" />).container.querySelector('button')!;
|
||||
expect(b.classList.contains('text-gray-600')).toBe(true);
|
||||
expect(b.classList.contains('hover:bg-gray-100')).toBe(true);
|
||||
});
|
||||
|
||||
it('variant success → bg-emerald-600', () => {
|
||||
expect(render(<Button variant="success" />).container.querySelector('button')!.classList.contains('bg-emerald-600')).toBe(true);
|
||||
});
|
||||
|
||||
it('size sm → px-2.5 py-1 text-xs', () => {
|
||||
const cls = render(<Button size="sm" />).container.querySelector('button')!.className;
|
||||
expect(cls).toContain('px-2.5');
|
||||
expect(cls).toContain('py-1');
|
||||
expect(cls).toContain('text-xs');
|
||||
});
|
||||
|
||||
it('size md → px-4 py-2 text-sm', () => {
|
||||
const cls = render(<Button />).container.querySelector('button')!.className;
|
||||
expect(cls).toContain('px-4');
|
||||
expect(cls).toContain('py-2');
|
||||
expect(cls).toContain('text-sm');
|
||||
});
|
||||
|
||||
it('size lg → px-6 py-3 text-base', () => {
|
||||
const cls = render(<Button size="lg" />).container.querySelector('button')!.className;
|
||||
expect(cls).toContain('px-6');
|
||||
expect(cls).toContain('py-3');
|
||||
expect(cls).toContain('text-base');
|
||||
});
|
||||
|
||||
it('disabled → HTMLButtonElement.disabled = true', () => {
|
||||
expect(render(<Button disabled />).container.querySelector('button')!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('loading → disabled + SVG spinner', () => {
|
||||
const { container } = render(<Button loading />);
|
||||
const b = container.querySelector('button')!;
|
||||
expect(b.disabled).toBe(true);
|
||||
expect(container.querySelector('svg.animate-spin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leftIcon renderizado', () => {
|
||||
const { container } = render(<Button leftIcon={<span data-testid="li">←</span>} />);
|
||||
expect(container.querySelector('#li') || container.querySelector('[data-testid="li"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rightIcon ausente durante loading', () => {
|
||||
const ri = render(
|
||||
<Button loading rightIcon={<span data-testid="ri">→</span>} />
|
||||
).container.querySelector('[data-testid="ri"]');
|
||||
expect(ri).toBeNull();
|
||||
});
|
||||
|
||||
it('rightIcon visível quando loading=false', () => {
|
||||
const ri = render(
|
||||
<Button rightIcon={<span data-testid="ri">→</span>} onClick={() => {}} />
|
||||
).container.querySelector('[data-testid="ri"]');
|
||||
expect(ri).toBeTruthy();
|
||||
});
|
||||
|
||||
it('chama onClick ao clicar', () => {
|
||||
const fn = vi.fn();
|
||||
render(<Button onClick={fn}>Click</Button>);
|
||||
fireEvent.click(render(<Button onClick={fn}>Click</Button>).container.querySelector('button')!);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('NÃO chama onClick quando disabled', () => {
|
||||
const fn = vi.fn();
|
||||
const b = render(<Button disabled onClick={fn} />).container.querySelector('button')!;
|
||||
fireEvent.click(b); // <-- chama diretamente no botão
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('NÃO chama onClick quando loading', () => {
|
||||
const fn = vi.fn();
|
||||
const b = render(<Button loading onClick={fn} />).container.querySelector('button')!;
|
||||
fireEvent.click(b);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mescla className custom', () => {
|
||||
expect(
|
||||
render(<Button className="min-w-[200px]" />).container.querySelector('button')!.classList.contains('min-w-[200px]')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('propaga aria-label e data-*', () => {
|
||||
const b = render(<Button aria-label="Salvar" data-action="salvar" />).container.querySelector('button')!;
|
||||
expect(b.getAttribute('aria-label')).toBe('Salvar');
|
||||
expect(b.getAttribute('data-action')).toBe('salvar');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Input
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
describe('Input', () => {
|
||||
it('renderiza <input> quando placeholder dado', () => {
|
||||
expect(render(<Input placeholder="x" />).container.querySelector('input')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renderiza <label> quando label é fornecido', () => {
|
||||
const c = render(<Input label="Nome" />).container;
|
||||
expect(c.querySelector('label')).toBeTruthy();
|
||||
expect(c.textContent).toContain('Nome');
|
||||
});
|
||||
|
||||
it('label conectado ao input via htmlFor = id gerado do label', () => {
|
||||
const c = render(<Input label="Senha" />).container;
|
||||
const lbl = c.querySelector('label') as HTMLLabelElement;
|
||||
const inp = c.querySelector('input') as HTMLInputElement;
|
||||
expect(inp.id).toBe(lbl.htmlFor);
|
||||
});
|
||||
|
||||
it('renderiza hint quando fornecido sem erro', () => {
|
||||
expect(render(<Input hint="dica" />).container.textContent).toContain('dica');
|
||||
});
|
||||
|
||||
it('erro SHOW, hint HIDE quando ambos fornecidos', () => {
|
||||
const c = render(<Input error="err" hint="dica" />).container;
|
||||
expect(c.textContent).toContain('err');
|
||||
const spans = c.querySelectorAll('span');
|
||||
const temDica = Array.from(spans).some((s) => s.textContent === 'dica');
|
||||
expect(temDica).toBe(false);
|
||||
});
|
||||
|
||||
it('span vazio quando nenhum hint nem erro', () => {
|
||||
expect(render(<Input />).container.querySelectorAll('span').length).toBe(0);
|
||||
});
|
||||
|
||||
it('id explícito não é sobrescrito pelo label', () => {
|
||||
const inp = render(<Input id="custom" label="X" />).container.querySelector('input') as HTMLInputElement;
|
||||
expect(inp.id).toBe('custom');
|
||||
});
|
||||
|
||||
it('type atributo passado para <input>', () => {
|
||||
expect(render(<Input type="tel" />).container.querySelector('input')!.getAttribute('type')).toBe('tel');
|
||||
});
|
||||
|
||||
it('aciona onChange', () => {
|
||||
const fn = vi.fn();
|
||||
const inp = render(<Input onChange={fn} />).container.querySelector('input')!;
|
||||
Object.defineProperty(inp, 'value', { writable: true, value: 'abc' }); // <-- jsdom não altera value naturalmente
|
||||
fireEvent.change(inp);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aciona onFocus e onBlur', () => {
|
||||
const fFn = vi.fn(), bFn = vi.fn();
|
||||
const inp = render(<Input onFocus={fFn} onBlur={bFn} />).container.querySelector('input')!;
|
||||
fireEvent.focus(inp);
|
||||
fireEvent.blur(inp);
|
||||
expect(fFn).toHaveBeenCalledTimes(1);
|
||||
expect(bFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mescla className custom', () => {
|
||||
expect(
|
||||
render(<Input className="text-xs" />).container.querySelector('input')!.classList.contains('text-xs')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('erro aplica border-red-500 + focus:ring-red-500', () => {
|
||||
const inp = render(<Input error="x" />).container.querySelector('input')!;
|
||||
expect(inp.classList.contains('border-red-500')).toBe(true);
|
||||
expect(inp.classList.contains('focus:ring-red-500')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Alert
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
describe('Alert', () => {
|
||||
it('renderiza children no DOM', () => {
|
||||
expect(render(<Alert>Msg</Alert>).container.textContent).toContain('Msg');
|
||||
});
|
||||
|
||||
it('renderiza titulo + children', () => {
|
||||
const c = render(<Alert title="T">B</Alert>).container;
|
||||
expect(c.textContent).toContain('T');
|
||||
expect(c.textContent).toContain('B');
|
||||
});
|
||||
|
||||
function alertDiv(): HTMLElement {
|
||||
return render(<Alert />).container.firstChild as HTMLElement;
|
||||
}
|
||||
|
||||
it('variant info (default) → bg-blue-50 border-blue-200 text-blue-800', () => {
|
||||
const cls = alertDiv().className;
|
||||
expect(cls).toContain('bg-blue-50');
|
||||
expect(cls).toContain('border-blue-200');
|
||||
expect(cls).toContain('text-blue-800');
|
||||
});
|
||||
|
||||
it('variant success → verde', () => {
|
||||
const cls = render(<Alert variant="success" />).container.firstChild!.className;
|
||||
expect(cls).toContain('bg-emerald-50');
|
||||
expect(cls).toContain('border-emerald-200');
|
||||
});
|
||||
|
||||
it('variant warning → amarelo', () => {
|
||||
expect(render(<Alert variant="warning" />).container.firstChild!.className).toContain('bg-amber-50');
|
||||
});
|
||||
|
||||
it('variant error → vermelho', () => {
|
||||
expect(render(<Alert variant="error" />).container.firstChild!.className).toContain('bg-red-50');
|
||||
});
|
||||
|
||||
it('botão × aparece quando onClose fornecido', () => {
|
||||
// O botão close é um <button> filho direto do alert
|
||||
const btns = render(<Alert onClose={() => {}} />).container.querySelectorAll('button');
|
||||
expect(btns.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('chama onClose ao clicar o botão ×', () => {
|
||||
const fn = vi.fn();
|
||||
const btns = render(<Alert onClose={fn} />).container.querySelectorAll('button');
|
||||
fireEvent.click(btns[0]);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('NÃO mostra botão × quando onClose ausente', () => {
|
||||
expect(render(<Alert />).container.querySelectorAll('button').length).toBe(0);
|
||||
});
|
||||
|
||||
it('emoji de variante aparece no DOM', () => {
|
||||
expect(render(<Alert variant="error" />).container.textContent).toContain('❌');
|
||||
expect(render(<Alert variant="success" />).container.textContent).toContain('✅');
|
||||
expect(render(<Alert variant="warning" />).container.textContent).toContain('⚠️');
|
||||
expect(render(<Alert variant="info" />).container.textContent).toContain('ℹ️');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Card
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
describe('Card', () => {
|
||||
it('renderiza div.rounde-xl', () => {
|
||||
const el = render(<Card>X</Card>).container.firstChild as HTMLElement;
|
||||
expect(el.classList.contains('rounded-xl')).toBe(true);
|
||||
expect(el.textContent).toContain('X');
|
||||
});
|
||||
|
||||
it('classes base completas', () => {
|
||||
const cls = render(<Card />).container.firstChild!.className;
|
||||
['rounded-xl', 'border', 'border-gray-200', 'bg-white', 'shadow-sm'].forEach((c) => {
|
||||
expect(cls).toContain(c);
|
||||
});
|
||||
});
|
||||
|
||||
it('mescla className custom', () => {
|
||||
expect(
|
||||
render(<Card className="mc" />).container.firstChild!.className
|
||||
).toContain('mc');
|
||||
});
|
||||
|
||||
it('aceita id + onClick', () => {
|
||||
const fn = vi.fn();
|
||||
const el = render(<Card id="c1" onClick={fn}>C</Card>).container.querySelector('#c1')!;
|
||||
expect(el).toBeTruthy();
|
||||
fireEvent.click(el);
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aninhamento sem perda', () => {
|
||||
const c = render(
|
||||
<Card>
|
||||
<CardHeader><CardTitle>T</CardTitle></CardHeader>
|
||||
<CardBody>B</CardBody>
|
||||
</Card>
|
||||
).container;
|
||||
expect(c.textContent).toContain('T');
|
||||
expect(c.textContent).toContain('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardHeader', () => {
|
||||
it('estilos px-5 py-4 border-b border-gray-100', () => {
|
||||
const cls = render(<CardHeader>H</CardHeader>).container.firstChild!.className;
|
||||
['px-5', 'py-4', 'border-b', 'border-gray-100'].forEach((c) => {
|
||||
expect(cls).toContain(c);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardTitle', () => {
|
||||
it('renderiza como <h3>', () => {
|
||||
expect(render(<CardTitle>T</CardTitle>).container.querySelector('h3')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('texto igual ao passado', () => {
|
||||
expect(render(<CardTitle>Título</CardTitle>).container.querySelector('h3')!.textContent).toBe('Título');
|
||||
});
|
||||
|
||||
it('estilos do título', () => {
|
||||
const cls = render(<CardTitle>T</CardTitle>).container.querySelector('h3')!.className;
|
||||
expect(cls).toContain('font-semibold');
|
||||
expect(cls).toContain('text-gray-900');
|
||||
expect(cls).toContain('text-base');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CardBody', () => {
|
||||
it('renderiza children', () => {
|
||||
expect(render(<CardBody>B</CardBody>).container.textContent).toContain('B');
|
||||
});
|
||||
|
||||
it('estilos px-5 py-4', () => {
|
||||
const cls = render(<CardBody>B</CardBody>).container.querySelector('div')!.className;
|
||||
expect(cls).toContain('px-5');
|
||||
expect(cls).toContain('py-4');
|
||||
});
|
||||
|
||||
it('mescla className custom', () => {
|
||||
expect(
|
||||
render(<CardBody className="bs" />).container.querySelector('div')!.className
|
||||
).toContain('bs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card — stress composição', () => {
|
||||
it('Header + Title + Body na mesma árvore', () => {
|
||||
const c = render(
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Título</CardTitle></CardHeader>
|
||||
<CardBody>Corpo</CardBody>
|
||||
</Card>
|
||||
).container;
|
||||
expect(c.querySelector('.shadow-sm')).toBeTruthy();
|
||||
expect(c.textContent).toContain('Título');
|
||||
expect(c.textContent).toContain('Corpo');
|
||||
});
|
||||
|
||||
it('100 linhas estrutura se mantém', () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `linha ${i + 1}`).join('\n');
|
||||
const c = render(
|
||||
<Card>
|
||||
<CardBody style={{ overflow: 'auto' }}>{lines}</CardBody>
|
||||
</Card>
|
||||
).container;
|
||||
expect(c.querySelector('.shadow-sm')).toBeTruthy();
|
||||
expect(c.querySelector('.border')).toBeTruthy();
|
||||
expect(c.textContent).toContain('linha 1');
|
||||
expect(c.textContent).toContain('linha 100');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user