import { describe, it, expect } from 'vitest'; import { applyMove, checkMove, locateCard, isAtLimit, isOverLimit, stepPosition, } from './kanban-model'; import type { KanbanColumn } from './kanban.types'; const board = (): KanbanColumn[] => [ { id: 'triagem', title: 'Triagem', cards: [ { id: 'a', title: 'Analisar petição' }, { id: 'b', title: 'Conferir anexos' }, { id: 'c', title: 'Distribuir' }, ], }, { id: 'andamento', title: 'Em andamento', limit: 2, cards: [{ id: 'd', title: 'Redigir parecer' }], }, { id: 'concluido', title: 'Concluído', cards: [] }, ]; const ids = (columns: KanbanColumn[], columnId: string) => columns.find(c => c.id === columnId)!.cards.map(c => c.id); describe('locateCard', () => { it('finds a card', () => { expect(locateCard(board(), 'b')).toEqual({ columnId: 'triagem', index: 1 }); }); it('returns null for a card that is not on the board', () => { expect(locateCard(board(), 'nope')).toBeNull(); }); }); describe('applyMove', () => { it('moves a card between columns', () => { const next = applyMove(board(), { cardId: 'a', from: { columnId: 'triagem', index: 0 }, to: { columnId: 'concluido', index: 0 }, }); expect(ids(next, 'triagem')).toEqual(['b', 'c']); expect(ids(next, 'concluido')).toEqual(['a']); }); it('inserts at the requested index, not just the end', () => { const next = applyMove(board(), { cardId: 'd', from: { columnId: 'andamento', index: 0 }, to: { columnId: 'triagem', index: 1 }, }); expect(ids(next, 'triagem')).toEqual(['a', 'd', 'b', 'c']); }); it('treats to.index as a slot with the card already lifted out', () => { /* * The coordinate system that matters: both drag paths exclude the card in * flight when computing the target, so index 1 in [a,b,c] holding 'a' means * "after b", not "where b is". */ const next = applyMove(board(), { cardId: 'a', from: { columnId: 'triagem', index: 0 }, to: { columnId: 'triagem', index: 1 }, }); expect(ids(next, 'triagem')).toEqual(['b', 'a', 'c']); }); it('reorders to the end of a column', () => { const next = applyMove(board(), { cardId: 'a', from: { columnId: 'triagem', index: 0 }, to: { columnId: 'triagem', index: 2 }, }); expect(ids(next, 'triagem')).toEqual(['b', 'c', 'a']); }); it('reorders upward', () => { const next = applyMove(board(), { cardId: 'c', from: { columnId: 'triagem', index: 2 }, to: { columnId: 'triagem', index: 0 }, }); expect(ids(next, 'triagem')).toEqual(['c', 'a', 'b']); }); it('is a no-op when a card is dropped where it already was', () => { const next = applyMove(board(), { cardId: 'b', from: { columnId: 'triagem', index: 1 }, to: { columnId: 'triagem', index: 1 }, }); expect(ids(next, 'triagem')).toEqual(['a', 'b', 'c']); }); it('does not mutate the input', () => { const original = board(); const snapshot = JSON.stringify(original); applyMove(original, { cardId: 'a', from: { columnId: 'triagem', index: 0 }, to: { columnId: 'concluido', index: 0 }, }); expect(JSON.stringify(original)).toBe(snapshot); }); it('ignores a move whose card does not match the source index', () => { const next = applyMove(board(), { cardId: 'a', from: { columnId: 'triagem', index: 2 }, to: { columnId: 'concluido', index: 0 }, }); expect(next).toEqual(board()); }); it('clamps an out-of-range target index', () => { const next = applyMove(board(), { cardId: 'a', from: { columnId: 'triagem', index: 0 }, to: { columnId: 'concluido', index: 99 }, }); expect(ids(next, 'concluido')).toEqual(['a']); }); }); describe('checkMove', () => { const move = (to: string) => ({ cardId: 'a', from: { columnId: 'triagem', index: 0 }, to: { columnId: to, index: 0 }, }); it('allows an ordinary move', () => { expect(checkMove(board(), move('concluido'))).toEqual({ allowed: true }); }); it('refuses a locked card', () => { const columns = board(); columns[0].cards[0].locked = true; expect(checkMove(columns, move('concluido'))).toEqual({ allowed: false, reason: 'locked-card', }); }); it('refuses a locked column', () => { const columns = board(); columns[2].locked = true; expect(checkMove(columns, move('concluido'))).toEqual({ allowed: false, reason: 'locked-column', }); }); it('warns but permits past a WIP limit by default', () => { const columns = board(); columns[1].cards.push({ id: 'e', title: 'Outro' }); // now at its limit of 2 expect(checkMove(columns, move('andamento'))).toEqual({ allowed: true }); }); it('refuses past a WIP limit when told to block', () => { const columns = board(); columns[1].cards.push({ id: 'e', title: 'Outro' }); expect(checkMove(columns, move('andamento'), 'block')).toEqual({ allowed: false, reason: 'wip-limit', }); }); it('never blocks a reorder inside a column on its own limit', () => { const columns = board(); columns[1].cards.push({ id: 'e', title: 'Outro' }); expect( checkMove( columns, { cardId: 'd', from: { columnId: 'andamento', index: 0 }, to: { columnId: 'andamento', index: 1 }, }, 'block' ) ).toEqual({ allowed: true }); }); }); describe('WIP limit state', () => { it('is at the limit when full and over it when exceeded', () => { const column = board()[1]; expect(isAtLimit({ ...column, cards: [{ id: 'x', title: 'x' }] })).toBe(false); expect( isAtLimit({ ...column, cards: [ { id: 'x', title: 'x' }, { id: 'y', title: 'y' }, ], }) ).toBe(true); expect( isOverLimit({ ...column, cards: [ { id: 'x', title: 'x' }, { id: 'y', title: 'y' }, ], }) ).toBe(false); expect( isOverLimit({ ...column, cards: [ { id: 'x', title: 'x' }, { id: 'y', title: 'y' }, { id: 'z', title: 'z' }, ], }) ).toBe(true); }); it('reports neither for a column with no limit', () => { expect(isAtLimit(board()[0])).toBe(false); expect(isOverLimit(board()[0])).toBe(false); }); }); describe('stepPosition', () => { const held = { columnId: 'triagem', index: 0 }; it('moves down within a column', () => { expect(stepPosition(board(), { columnId: 'triagem', index: 0 }, 'down', held)).toEqual({ columnId: 'triagem', index: 1, }); }); it('stops at the top and the bottom', () => { expect(stepPosition(board(), { columnId: 'triagem', index: 0 }, 'up', held).index).toBe(0); // Two cards remain once the held one is lifted out. expect(stepPosition(board(), { columnId: 'triagem', index: 2 }, 'down', held).index).toBe(2); }); it('crosses to the next column', () => { expect(stepPosition(board(), { columnId: 'triagem', index: 0 }, 'right', held).columnId).toBe( 'andamento' ); }); it('stops at the first and last column', () => { expect(stepPosition(board(), { columnId: 'triagem', index: 0 }, 'left', held).columnId).toBe( 'triagem' ); expect(stepPosition(board(), { columnId: 'concluido', index: 0 }, 'right', held).columnId).toBe( 'concluido' ); }); it('clamps the index when crossing into a shorter column', () => { // A keyboard drag that silently does nothing is a dead end, so it lands. expect(stepPosition(board(), { columnId: 'triagem', index: 2 }, 'right', held)).toEqual({ columnId: 'andamento', index: 1, }); }); });