taskOrder.test.ts8.1 KBView on GitHub import { compareTasks, sortOrderForDrop } from '@/modules/userTasks/utils/task-order';
import type { OrderableTask } from '@/modules/userTasks/utils/task-order';
/**
* How cards order inside a board column, a list section or the execution sidebar.
*
* There is ONE order and it is stored in `sortOrder`. The toolbar's Ordering re-seeds that column
* rather than choosing a comparator, which is what lets a card be dragged whatever ordering you
* picked — there is no mode to be in or out of. These tests pin that the comparator consults
* nothing else, and that a `sortOrder` collision still resolves deterministically.
*/
function t(over: Partial<OrderableTask> & { id: string }): OrderableTask & { id: string } {
return { sortOrder: 0, dueDate: null, createdAt: null, ...over };
}
/** Deliberately disagrees on all three axes, so the comparator can't accidentally look right. */
const TASKS = [
t({ id: 'a', sortOrder: 3, dueDate: '2026-08-27T09:00:00Z', createdAt: '2026-08-01T09:00:00Z' }),
t({ id: 'b', sortOrder: 1, dueDate: '2026-08-20T09:00:00Z', createdAt: '2026-08-10T09:00:00Z' }),
t({ id: 'c', sortOrder: 2, dueDate: '2026-08-25T09:00:00Z', createdAt: '2026-08-05T09:00:00Z' }),
];
describe('compareTasks', () => {
it('sorts by sortOrder, ignoring both dates', () => {
// Due date alone would give [a, c, b]; createdAt alone [b, c, a] — neither is consulted.
expect([...TASKS].sort(compareTasks()).map((x) => x.id)).toEqual(['b', 'c', 'a']);
});
it('takes no mode argument — the ordering control changes the data, not the comparator', () => {
expect(compareTasks.length).toBe(0);
});
it('breaks a sortOrder tie by due date, then id — a collision is harmless, not arbitrary', () => {
// Two rows CAN share a sortOrder: placement midpoints the neighbours it reads, so a burst of
// agent-created tasks inserting at once lands on the same number. Without a tiebreak the
// order of those cards depends on the wire order, and the board disagrees with itself
// between renders.
const tied = [
t({ id: 'z', sortOrder: 5, dueDate: '2026-08-20T09:00:00Z' }),
t({ id: 'y', sortOrder: 5, dueDate: '2026-08-27T09:00:00Z' }),
t({ id: 'x', sortOrder: 5, dueDate: '2026-08-27T09:00:00Z' }),
];
expect([...tied].sort(compareTasks()).map((v) => v.id)).toEqual(['x', 'y', 'z']);
// Same set, opposite input order — the result must not move.
expect(
[...tied]
.reverse()
.sort(compareTasks())
.map((v) => v.id),
).toEqual(['x', 'y', 'z']);
});
it('treats a missing sortOrder as 0 rather than NaN', () => {
// An optimistic row reaches the comparator before the server has ever placed it. NaN would
// make the sort non-deterministic rather than merely putting the card at the top.
const optimistic = [t({ id: 'placed', sortOrder: 2 }), { id: 'fresh' } as OrderableTask];
expect([...optimistic].sort(compareTasks()).map((v) => v.id)).toEqual(['fresh', 'placed']);
});
it('orders negative and fractional values correctly — both are real placements', () => {
// A drop above the top card writes `first - 1`, and repeated drops halve the gap.
const dropped = [
t({ id: 'bottom', sortOrder: 2 }),
t({ id: 'top', sortOrder: -1 }),
t({ id: 'middle', sortOrder: 1.5 }),
];
expect([...dropped].sort(compareTasks()).map((v) => v.id)).toEqual(['top', 'middle', 'bottom']);
});
describe('the authorship band', () => {
it('puts everything the user typed above everything the agent made', () => {
// sortOrder says the opposite in every pair here, so only the band can produce this.
const mixed = [
t({ id: 'agent-top', sortOrder: -50, taskCreatedBy: 'agent' }),
t({ id: 'mine-late', sortOrder: 900, taskCreatedBy: 'user' }),
t({ id: 'agent-mid', sortOrder: -10, taskCreatedBy: 'agent' }),
t({ id: 'mine-early', sortOrder: 100, taskCreatedBy: 'user' }),
];
expect([...mixed].sort(compareTasks()).map((v) => v.id)).toEqual([
'mine-early',
'mine-late',
'agent-top',
'agent-mid',
]);
});
it('still orders by sortOrder WITHIN a band — a drag keeps working', () => {
// The band is an invariant, not a replacement for the order. If this ever collapses to
// "author only", dropping a card anywhere in its own band silently does nothing.
const mine = [
t({ id: 'third', sortOrder: 3, taskCreatedBy: 'user' }),
t({ id: 'first', sortOrder: 1, taskCreatedBy: 'user' }),
t({ id: 'second', sortOrder: 2, taskCreatedBy: 'user' }),
];
expect([...mine].sort(compareTasks()).map((v) => v.id)).toEqual(['first', 'second', 'third']);
});
it('ranks an unrecorded author with the agent, not with the user', () => {
// `null` is a legacy row from before the column existed. Promoting it would put thousands
// of old agent cards in the band that is supposed to mean "you wrote this".
const legacy = [
t({ id: 'null-author', sortOrder: -5, taskCreatedBy: null }),
t({ id: 'undefined-author', sortOrder: -6 }),
t({ id: 'mine', sortOrder: 99, taskCreatedBy: 'user' }),
];
expect([...legacy].sort(compareTasks()).map((v) => v.id)).toEqual([
'mine',
'undefined-author',
'null-author',
]);
});
it('lands a newly created manual task at the very top', () => {
// A new task is due now(), so placement gives it the smallest sortOrder among the user's
// own cards — and the band keeps it above the agent's regardless of what they hold.
const board = [
t({ id: 'agent-urgent', sortOrder: -100, taskCreatedBy: 'agent' }),
t({ id: 'my-older', sortOrder: 5, taskCreatedBy: 'user' }),
t({ id: 'my-new', sortOrder: 4, taskCreatedBy: 'user' }),
];
expect([...board].sort(compareTasks())[0]?.id).toBe('my-new');
});
it('resolves a cross-band drop by the band, not the dropped position', () => {
// `sortOrderForDrop` will happily write a value that sits among agent cards. The card must
// come to rest at the bottom of its OWN band rather than where it was released.
const column = [
t({ id: 'agent-a', sortOrder: 1, taskCreatedBy: 'agent' }),
t({ id: 'agent-b', sortOrder: 2, taskCreatedBy: 'agent' }),
];
const droppedBetween = [column[0], t({ id: 'mine' }), column[1]];
const mine = t({
id: 'mine',
sortOrder: sortOrderForDrop(droppedBetween, 1),
taskCreatedBy: 'user',
});
expect([...column, mine].sort(compareTasks()).map((v) => v.id)).toEqual([
'mine',
'agent-a',
'agent-b',
]);
});
});
});
describe('sortOrderForDrop', () => {
// The column AFTER the drop, i.e. the result of dnd-kit's arrayMove.
const column = [t({ id: 'x', sortOrder: 1 }), t({ id: 'y', sortOrder: 2 })];
it('midpoints the two new neighbours', () => {
const dropped = [column[0], t({ id: 'moved' }), column[1]];
expect(sortOrderForDrop(dropped, 1)).toBe(1.5);
});
it('steps above the first card when dropped at the top', () => {
const dropped = [t({ id: 'moved' }), ...column];
expect(sortOrderForDrop(dropped, 0)).toBe(0);
});
it('steps below the last card when dropped at the bottom', () => {
const dropped = [...column, t({ id: 'moved' })];
expect(sortOrderForDrop(dropped, 2)).toBe(3);
});
it('returns 0 for the only card in an empty column', () => {
expect(sortOrderForDrop([t({ id: 'moved' })], 0)).toBe(0);
});
it('keeps producing a value strictly between its neighbours when dropped repeatedly', () => {
// Every drop writes ONE row, so the gap halves rather than the column renumbering.
let board = [t({ id: 'x', sortOrder: 1 }), t({ id: 'y', sortOrder: 2 })];
for (let i = 0; i < 5; i++) {
const dropped = [board[0], t({ id: `n${i}` }), ...board.slice(1)];
const placed = sortOrderForDrop(dropped, 1);
expect(placed).toBeGreaterThan(board[0].sortOrder!);
expect(placed).toBeLessThan(board[1].sortOrder!);
board = [board[0], t({ id: `n${i}`, sortOrder: placed }), ...board.slice(1)];
}
});
});