import { render, screen, fireEvent, within } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { Kanban } from './kanban'; import { applyMove } from './kanban-model'; import type { KanbanColumn } from './kanban.types'; const COLUMNS: KanbanColumn[] = [ { id: 'triagem', title: 'Triagem', cards: [ { id: 'a', title: 'Analisar petição' }, { id: 'b', title: 'Conferir anexos' }, ], }, { id: 'andamento', title: 'Em andamento', limit: 2, cards: [{ id: 'c', title: 'Redigir parecer' }], }, { id: 'concluido', title: 'Concluído', cards: [] }, ]; /** A board that actually applies moves, as a consumer would. */ function ControlledBoard(props: Partial>) { const [columns, setColumns] = React.useState(COLUMNS); return ( setColumns(current => applyMove(current, move))} {...props} /> ); } const cardTitlesIn = (columnName: string) => within(screen.getByRole('list', { name: columnName })) .getAllByRole('button') .map(node => node.getAttribute('aria-label')); describe('Kanban', () => { it('renders every column and card', () => { render(); expect(screen.getByRole('button', { name: 'Analisar petição' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Redigir parecer' })).toBeInTheDocument(); }); it('names each column with its card count', () => { render(); expect(screen.getByRole('region', { name: 'Triagem, 2 cards' })).toBeInTheDocument(); }); it('shows the work-in-progress limit', () => { render(); expect(screen.getByText('1 / 2')).toBeInTheDocument(); }); it('says so when a column is empty', () => { render(); expect(screen.getByText('No cards')).toBeInTheDocument(); }); it('opens a card on click', () => { const onCardActivate = vi.fn(); render(); const card = screen.getByRole('button', { name: 'Conferir anexos' }); fireEvent.pointerDown(card, { button: 0, clientX: 10, clientY: 10 }); fireEvent.pointerUp(card); expect(onCardActivate).toHaveBeenCalledWith('b', expect.objectContaining({ id: 'b' })); }); it('marks the selected card', () => { render(); expect(screen.getByRole('button', { name: 'Conferir anexos' })).toHaveAttribute( 'aria-pressed', 'true' ); }); describe('keyboard drag', () => { it('moves a card to the next column and drops it', () => { render(); const card = screen.getByRole('button', { name: 'Analisar petição' }); fireEvent.keyDown(card, { key: ' ' }); fireEvent.keyDown(card, { key: 'ArrowRight' }); fireEvent.keyDown(card, { key: ' ' }); expect(cardTitlesIn('Em andamento')).toContain('Analisar petição'); expect(cardTitlesIn('Triagem')).not.toContain('Analisar petição'); }); it('reorders within a column', () => { render(); const card = screen.getByRole('button', { name: 'Analisar petição' }); fireEvent.keyDown(card, { key: ' ' }); fireEvent.keyDown(card, { key: 'ArrowDown' }); fireEvent.keyDown(card, { key: ' ' }); expect(cardTitlesIn('Triagem')).toEqual(['Conferir anexos', 'Analisar petição']); }); it('restores the card on Escape', () => { render(); const card = screen.getByRole('button', { name: 'Analisar petição' }); fireEvent.keyDown(card, { key: ' ' }); fireEvent.keyDown(card, { key: 'ArrowRight' }); fireEvent.keyDown(card, { key: 'Escape' }); expect(cardTitlesIn('Triagem')).toContain('Analisar petição'); expect(cardTitlesIn('Em andamento')).not.toContain('Analisar petição'); }); it('marks the held card as grabbed', () => { render(); const card = screen.getByRole('button', { name: 'Analisar petição' }); fireEvent.keyDown(card, { key: ' ' }); expect(card).toHaveAttribute('aria-grabbed', 'true'); }); it('narrates every step, or a keyboard drag is invisible', () => { const { container } = render(); const live = container.querySelector('[aria-live="assertive"]')!; const card = screen.getByRole('button', { name: 'Analisar petição' }); fireEvent.keyDown(card, { key: ' ' }); expect(live).toHaveTextContent(/Analisar petição grabbed/); fireEvent.keyDown(card, { key: 'ArrowRight' }); expect(live).toHaveTextContent(/moved to position 1 of 2 in Em andamento/); }); it('opens the card with Enter when nothing is held', () => { const onCardActivate = vi.fn(); render(); fireEvent.keyDown(screen.getByRole('button', { name: 'Conferir anexos' }), { key: 'Enter' }); expect(onCardActivate).toHaveBeenCalledWith('b', expect.objectContaining({ id: 'b' })); }); }); describe('work-in-progress limit', () => { const full: KanbanColumn[] = [ { id: 'triagem', title: 'Triagem', cards: [{ id: 'a', title: 'Um' }] }, { id: 'andamento', title: 'Em andamento', limit: 1, cards: [{ id: 'b', title: 'Dois' }], }, ]; it('lets a move through with a warning by default', () => { const onMove = vi.fn(); render(); const card = screen.getByRole('button', { name: 'Um' }); fireEvent.keyDown(card, { key: ' ' }); fireEvent.keyDown(card, { key: 'ArrowRight' }); fireEvent.keyDown(card, { key: ' ' }); expect(onMove).toHaveBeenCalled(); }); it('refuses and explains itself when told to block', () => { const onMove = vi.fn(); const { container } = render( ); const card = screen.getByRole('button', { name: 'Um' }); fireEvent.keyDown(card, { key: ' ' }); fireEvent.keyDown(card, { key: 'ArrowRight' }); fireEvent.keyDown(card, { key: ' ' }); expect(onMove).not.toHaveBeenCalled(); // A drop that silently does nothing reads as a broken component. expect(container.querySelector('[aria-live="assertive"]')).toHaveTextContent( /work-in-progress limit/ ); }); }); describe('locking', () => { it('does not let a locked card be grabbed', () => { const columns: KanbanColumn[] = [ { id: 'a', title: 'A', cards: [{ id: 'x', title: 'Fixo', locked: true }] }, { id: 'b', title: 'B', cards: [] }, ]; render(); const card = screen.getByRole('button', { name: 'Fixo' }); fireEvent.keyDown(card, { key: ' ' }); expect(card).not.toHaveAttribute('aria-grabbed'); }); it('read-only disables dragging but keeps the board readable', () => { render(); const card = screen.getByRole('button', { name: 'Analisar petição' }); fireEvent.keyDown(card, { key: ' ' }); expect(card).not.toHaveAttribute('aria-grabbed'); expect(card).toBeInTheDocument(); }); }); it('accepts a custom card renderer', () => { render( custom: {card.title}} />); expect(screen.getByText('custom: Analisar petição')).toBeInTheDocument(); }); });