taskKanbanSorting.test.ts4.6 KBView on GitHub
import { noSortingStrategy } from '@/modules/userTasks/components/TaskKanbanColumn';
import { arrangeForDrag, type TaskColumn } from '@/modules/userTasks/components/TaskKanbanBoard';
import { planTaskDrop, MISC_KEY } from '@/modules/userTasks/utils/task-drop-plan';
import type { TaskCardTask } from '@/modules/userTasks/components/TaskKanbanCard';

/**
 * One answer about where the dragged card goes.
 *
 * The board arranges its own lanes: it renders the dragged card at the slot the pointer resolved,
 * and the cards part through plain DOM order. dnd-kit is declined the same job, because a second
 * opinion about where the cards sit is a second opinion about where the card will LAND — and the
 * two disagreed, since `verticalListSortingStrategy` shifts by `activeIndex` vs `overIndex` (into
 * the list the board has already rearranged, against an `over` from dnd-kit's own collision
 * detection) while the board resolves the slot from the pointer against frozen midpoints.
 *
 * Crossing a card's midpoint moved the board's gap one way and dnd-kit's displacement the other,
 * so a card visibly settled into a slot the drop was never going to write and then snapped back on
 * release. Reported as the hitbox not matching the animation, which is what it was.
 *
 * Two invariants, both of which have broken in this component:
 *
 *   1. A lane displaces NOTHING of its own. Ever, on any axis, modifier or not.
 *   2. The strategy identity is STABLE. `SortableContext` re-registers when it changes, dnd-kit
 *      re-measures its droppables, and `measureRect` calls setState — a fresh closure per render
 *      is an infinite render loop ("Maximum update depth exceeded"), not a wasted allocation.
 */
describe('noSortingStrategy', () => {
  const args = { activeNodeRect: null, activeIndex: 0, index: 1, rects: [], overIndex: 2 };

  it('displaces nothing, whatever dnd-kit thinks the card is over', () => {
    expect(noSortingStrategy(args)).toBeNull();
    expect(noSortingStrategy({ ...args, overIndex: 0 })).toBeNull();
    expect(noSortingStrategy({ ...args, index: 0 })).toBeNull();
  });

  it('is one constant, so SortableContext never re-registers', () => {
    expect(noSortingStrategy).toBe(noSortingStrategy);
  });
});

// ── The arrangement the render and the drop share ─────────────────────────────

const task = (id: string, sortOrder: number): TaskCardTask =>
  ({ id, sortOrder, description: id, conversationId: null, status: 'todo' }) as TaskCardTask;

const lane = (key=[redacted], tasks: TaskCardTask[]): TaskColumn => ({
  key,
  label: key,
  color: null,
  icon: null,
  tasks,
  droppableId: key,
  canDrop: true,
  canDrag: true,
});

const A = task('a', 1);
const B = task('b', 2);
const C = task('c', 3);
const D = task('d', 4);

describe('arrangeForDrag', () => {
  const columns = [lane(MISC_KEY, [A, B, C, D]), lane('other', [])];

  it('moves the dragged card to the resolved slot, leaving the rest in order', () => {
    const [misc] = arrangeForDrag(columns, 'a', { columnKey=[redacted], index: 2 });
    expect(misc!.tasks.map((t) => t.id)).toEqual(['b', 'c', 'a', 'd']);
  });

  it('lifts the card out of its lane entirely when the placement is in another one', () => {
    const [misc, other] = arrangeForDrag(columns, 'a', { columnKey=[redacted], index: 0 });
    expect(misc!.tasks.map((t) => t.id)).toEqual(['b', 'c', 'd']);
    expect(other!.tasks.map((t) => t.id)).toEqual(['a']);
  });

  it('leaves the board untouched with no placement — the card holds its slot', () => {
    const [misc] = arrangeForDrag(columns, 'a', null);
    expect(misc!.tasks.map((t) => t.id)).toEqual(['a', 'b', 'c', 'd']);
  });

  it('clamps a slot past the end of the lane rather than dropping the card', () => {
    const [misc] = arrangeForDrag(columns, 'a', { columnKey=[redacted], index: 99 });
    expect(misc!.tasks.map((t) => t.id)).toEqual(['b', 'c', 'd', 'a']);
  });

  /**
   * The point of the extraction: the drop plans against this arrangement, so the position written
   * is read back off the same list the user was looking at. Previously the render and the drop
   * each built their own, which is how they came to disagree.
   */
  it('gives the drop a sortOrder between the neighbours the arrangement shows', () => {
    const placement = { columnKey=[redacted], index: 2 };
    const plan = planTaskDrop({
      columns: arrangeForDrag(columns, 'a', placement),
      taskId: 'a',
      targetColumnKey=[redacted],
      originColumnKey=[redacted],
      columnBy: 'group',
      reorder: true,
    });
    // Shown as [b, c, a, d] → between c (3) and d (4).
    expect(plan).toMatchObject({ kind: 'move', sortOrder: 3.5 });
  });
});