import { describe, expect, it } from 'vitest' import { applyPendingMove, hasMoveSettled, type PendingMove } from '../components/features/board/pending-move' import type { BoardColumnDef } from '../components/features/board/types' const ticket = (id: string) => ({ id, title: id, ticketNumber: `#${id}`, status: 'open' }) const columns = (open: string[], done: string[]): BoardColumnDef[] => [ { id: 'open', label: 'Open', color: '#5ea700', tickets: open.map(ticket) }, { id: 'done', label: 'Done', color: '#1f6feb', tickets: done.map(ticket) }, ] /** `a2` was dragged out of Open and dropped into Done, straight after `b1`. */ const MOVE: PendingMove = { ticketId: 'a2', fromColumnId: 'open', toColumnId: 'done', afterTicketId: 'b1' } describe('hasMoveSettled', () => { it('is unsettled while the card is still in the lane it came from', () => { expect(hasMoveSettled(columns(['a1', 'a2', 'a3'], ['b1', 'b2']), MOVE)).toBe(false) }) it('settles when the card turns up exactly where it was dropped', () => { expect(hasMoveSettled(columns(['a1', 'a3'], ['b1', 'a2', 'b2']), MOVE)).toBe(true) }) // The failure this test exists for: each lane polls on its own timer and takes // live updates, so the lane a card came FROM changes for reasons that have // nothing to do with the move. Reading any of that as "landed" uncovers the // card in its old place — the flash the whole mechanism exists to remove. it('stays unsettled when the lane it came from changes for unrelated reasons', () => { expect(hasMoveSettled(columns(['a0', 'a1', 'a2', 'a3'], ['b1', 'b2']), MOVE)).toBe(false) expect(hasMoveSettled(columns(['a2'], ['b1', 'b2']), MOVE)).toBe(false) }) // A server free to sort the destination lane its own way will never reproduce // the dropped index. Holding out for one it will not send means the board // shows a position it has already been told is wrong, until the timeout jerks // it into place seconds later. it('settles anywhere in the new lane once the old one has let go', () => { expect(hasMoveSettled(columns(['a1', 'a3'], ['a2', 'b1', 'b2']), MOVE)).toBe(true) expect(hasMoveSettled(columns(['a1', 'a3'], ['b1', 'b2', 'a2']), MOVE)).toBe(true) }) // The two lanes are separate queries and answer at their own pace. Handing // back while both still list the card shows it in two places at once. it('stays unsettled while both lanes still list the card', () => { expect(hasMoveSettled(columns(['a1', 'a2', 'a3'], ['b1', 'a2', 'b2']), MOVE)).toBe(false) }) it('recognises a drop at the head of a lane', () => { const toHead: PendingMove = { ticketId: 'a2', fromColumnId: 'open', toColumnId: 'done', afterTicketId: null } expect(hasMoveSettled(columns(['a1', 'a3'], ['a2', 'b1']), toHead)).toBe(true) }) // Within one lane the card is in that lane whether the move landed or not, so // here the exact slot is the only thing that can answer. it('holds a reorder inside one lane to the exact slot', () => { const withinLane: PendingMove = { ticketId: 'a1', fromColumnId: 'open', toColumnId: 'open', afterTicketId: 'a2' } expect(hasMoveSettled(columns(['a1', 'a2', 'a3'], []), withinLane)).toBe(false) expect(hasMoveSettled(columns(['a2', 'a1', 'a3'], []), withinLane)).toBe(true) }) it('is unsettled when the lane it was dropped into is gone', () => { expect(hasMoveSettled([{ id: 'open', label: 'Open', color: '#5ea700', tickets: [] }], MOVE)).toBe(false) }) }) describe('applyPendingMove', () => { it('shows the card in the lane it was dropped into, in the slot it was dropped in', () => { const moved = applyPendingMove(columns(['a1', 'a2', 'a3'], ['b1', 'b2']), MOVE) expect(moved[0].tickets.map(t => t.id)).toEqual(['a1', 'a3']) expect(moved[1].tickets.map(t => t.id)).toEqual(['b1', 'a2', 'b2']) }) it('drops the card at the head of the lane', () => { const toHead: PendingMove = { ticketId: 'a2', fromColumnId: 'open', toColumnId: 'done', afterTicketId: null } const moved = applyPendingMove(columns(['a1', 'a2'], ['b1']), toHead) expect(moved[1].tickets.map(t => t.id)).toEqual(['a2', 'b1']) }) it('reorders inside one lane', () => { const within: PendingMove = { ticketId: 'a1', fromColumnId: 'open', toColumnId: 'open', afterTicketId: 'a3' } const moved = applyPendingMove(columns(['a1', 'a2', 'a3'], []), within) expect(moved[0].tickets.map(t => t.id)).toEqual(['a2', 'a3', 'a1']) }) // The whole point of the optimistic view: once the host agrees, applying the // move to its answer has to be a no-op, or handing back would move the card // a second time. it('is idempotent once the host has caught up', () => { const settled = columns(['a1', 'a3'], ['b1', 'a2', 'b2']) expect(hasMoveSettled(settled, MOVE)).toBe(true) const moved = applyPendingMove(settled, MOVE) expect(moved[0].tickets.map(t => t.id)).toEqual(['a1', 'a3']) expect(moved[1].tickets.map(t => t.id)).toEqual(['b1', 'a2', 'b2']) }) it('appends when the card it was dropped after is gone', () => { const moved = applyPendingMove(columns(['a1', 'a2'], ['b2']), MOVE) expect(moved[1].tickets.map(t => t.id)).toEqual(['b2', 'a2']) }) it('leaves untouched lanes identical, so memoized lanes can skip the render', () => { const before = columns(['a1', 'a2'], ['b1']) const extra = [...before, { id: 'cold', label: 'Cold', color: '#333', tickets: [ticket('c1')] }] const moved = applyPendingMove(extra, MOVE) expect(moved[2]).toBe(extra[2]) }) it('changes nothing when the card or the lane is unknown', () => { const before = columns(['a1'], ['b1']) expect(applyPendingMove(before, { ...MOVE, ticketId: 'ghost' })).toBe(before) expect(applyPendingMove(before, { ...MOVE, toColumnId: 'ghost' })).toBe(before) }) })