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 });
});
});