userTasksSlice.test.ts5.7 KBView on GitHub
/**
 * Tests for the UserTasksSlice additions that make the task board render solely from the slice:
 *   - hydrateTodoTasks — authoritative hydration of the open working set (upsert + reconcile removals)
 *   - task board bulk selection (x / shift+x): toggle / set / clear / anchor
 *
 * See TASK_SLICE_REFACTOR_DESIGN.md.
 */
import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/store';
import type { HydratedUserTask } from '@/modules/userTasks/slice/userTasksSlice';

/** Minimal HydratedUserTask for slice-logic tests — only the fields the reconciler reads matter. */
function task(id: string, status: HydratedUserTask['status'] = 'todo'): HydratedUserTask {
  return {
    id,
    userId: 'u1',
    conversationId: `conv-${id}`,
    taskGroupId: null,
    taskChannel: 'email',
    taskType: 'response',
    taskCreatedBy: 'agent',
    taskActionData: null,
    agentExecutionEnabled: false,
    executionRunId: null,
    creationRunId: null,
    notes: null,
    chatThreadId: null,
    description: `task ${id}`,
    status,
    isRead: false,
    createdAt: new Date('2026-01-01'),
    updatedAt: new Date('2026-01-01'),
    completedAt: null,
    dueDate: new Date('2026-01-02'),
    taskOutput: null,
    sourceThreadId: null,
    sourceDocumentId: null,
    sourceMarkerId: null,
    tags: [],
    sortOrder: 0,
    sortOrderPinned: false,
  };
}

const reset = () =>
  useCedarStore.setState((s) => ({
    ...s,
    tasks: {},
    taskSelection: [],
    taskSelectionAnchorId: null,
  }));

beforeEach(reset);
afterEach(reset);

// ---------------------------------------------------------------------------
// hydrateTodoTasks
// ---------------------------------------------------------------------------

describe('hydrateTodoTasks', () => {
  it('upserts all incoming tasks into the slice', () => {
    act(() => useCedarStore.getState().hydrateTodoTasks([task('a'), task('b')]));
    const { tasks } = useCedarStore.getState();
    expect(Object.keys(tasks).sort()).toEqual(['a', 'b']);
  });

  it('removes a todo task absent from the authoritative list (server-completed elsewhere)', () => {
    act(() => useCedarStore.getState().hydrateTodoTasks([task('a'), task('b')]));
    // Next fetch no longer includes 'a' — it was completed/deleted server-side.
    act(() => useCedarStore.getState().hydrateTodoTasks([task('b')]));
    const { tasks } = useCedarStore.getState();
    expect(Object.keys(tasks)).toEqual(['b']);
  });

  it('does NOT remove non-todo tasks that are absent from the list', () => {
    // A 'done' task held for another surface must survive a todo-scoped hydration.
    act(() => useCedarStore.setState((s) => ({ ...s, tasks: { d: task('d', 'done') } })));
    act(() => useCedarStore.getState().hydrateTodoTasks([task('a')]));
    const { tasks } = useCedarStore.getState();
    expect(Object.keys(tasks).sort()).toEqual(['a', 'd']);
    expect(tasks.d.status).toBe('done');
  });

  it('reflects updated fields on re-hydration (upsert, not ignore)', () => {
    act(() => useCedarStore.getState().hydrateTodoTasks([task('a')]));
    const updated = { ...task('a'), description: 'changed' };
    act(() => useCedarStore.getState().hydrateTodoTasks([updated]));
    expect(useCedarStore.getState().tasks.a.description).toBe('changed');
  });

  it('empty list clears the todo working set', () => {
    act(() => useCedarStore.getState().hydrateTodoTasks([task('a'), task('b')]));
    act(() => useCedarStore.getState().hydrateTodoTasks([]));
    expect(Object.keys(useCedarStore.getState().tasks)).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// Task board bulk selection
// ---------------------------------------------------------------------------

describe('toggleTaskSelection', () => {
  it('adds a task when not selected', () => {
    act(() => useCedarStore.getState().toggleTaskSelection('a'));
    expect(useCedarStore.getState().taskSelection).toEqual(['a']);
  });

  it('removes a task when already selected', () => {
    act(() => useCedarStore.getState().toggleTaskSelection('a'));
    act(() => useCedarStore.getState().toggleTaskSelection('a'));
    expect(useCedarStore.getState().taskSelection).toEqual([]);
  });

  it('preserves insertion order across independent toggles', () => {
    act(() => useCedarStore.getState().toggleTaskSelection('a'));
    act(() => useCedarStore.getState().toggleTaskSelection('b'));
    expect(useCedarStore.getState().taskSelection).toEqual(['a', 'b']);
  });
});

describe('setTaskSelection', () => {
  it('replaces the selection and dedupes', () => {
    act(() => useCedarStore.getState().setTaskSelection(['a', 'b', 'a', 'c']));
    expect(useCedarStore.getState().taskSelection).toEqual(['a', 'b', 'c']);
  });
});

describe('clearTaskSelection', () => {
  it('clears selection and the range anchor', () => {
    act(() => {
      useCedarStore.getState().setTaskSelection(['a', 'b']);
      useCedarStore.getState().setTaskSelectionAnchorId('a');
    });
    act(() => useCedarStore.getState().clearTaskSelection());
    expect(useCedarStore.getState().taskSelection).toEqual([]);
    expect(useCedarStore.getState().taskSelectionAnchorId).toBeNull();
  });
});

describe('setTaskSelectionAnchorId / isTaskSelected', () => {
  it('persists the anchor for range-select', () => {
    act(() => useCedarStore.getState().setTaskSelectionAnchorId('a'));
    expect(useCedarStore.getState().taskSelectionAnchorId).toBe('a');
  });

  it('isTaskSelected reflects membership', () => {
    act(() => useCedarStore.getState().setTaskSelection(['a']));
    expect(useCedarStore.getState().isTaskSelected('a')).toBe(true);
    expect(useCedarStore.getState().isTaskSelected('z')).toBe(false);
  });
});