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