threadSlice.test.ts20.3 KBView on GitHub
/**
 * Tests for ThreadSlice —
 *   shallowCompareThreadData (pure function)
 *   setThreadData (equality skip, temp-draft preservation)
 *   navigateToNextThread / navigateToPreviousThread
 *   pushUndo / popUndo (TTL)
 *   toggleStar / markAsRead / toggleImportant
 *   populateThreadMetadata (placeholder creation)
 */

import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { shallowCompareThreadData } from '@/modules/threads/threadList/store/threadSlice';
import type { ThreadData } from '@/modules/threads/threadList/store/threadSlice';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const makeMessage = (overrides: Record<string, unknown> = {}) => ({
  id: 'msg-1',
  threadId: 'thread-1',
  isDraft: false,
  subject: 'Hello',
  sender: { email: '<email>', name: 'Alice' },
  to: [],
  cc: null,
  bcc: null,
  tls: true,
  receivedOn: '2026-01-01T00:00:00.000Z',
  unread: false,
  processedHtml: '',
  blobUrl: '',
  tags: [],
  snippet: '',
  ...overrides,
});

const makeThread = (overrides: Partial<ThreadData> = {}): ThreadData => ({
  id: 'thread-1',
  messages: [makeMessage() as any],
  latest: makeMessage() as any,
  hasUnread: false,
  totalReplies: 1,
  labels: [],
  lastLoadedAt: Date.now(),
  ...overrides,
});

const resetThreadState = () =>
  useCedarStore.setState((s) => ({
    ...s,
    threadData: {},
    selectedThreadId: null,
    isThreadOpen: false,
    activeConversationId: null,
    currentThreadList: [],
    focusedIndex: null,
    keyboardActive: false,
    undoEntry: null,
    undoStack: [],
    redoStack: [],
    bulkSelected: [],
  }));

beforeEach(resetThreadState);
afterEach(resetThreadState);

// ---------------------------------------------------------------------------
// shallowCompareThreadData — pure function tests
// ---------------------------------------------------------------------------

describe('shallowCompareThreadData', () => {
  it('returns true for identical references', () => {
    const t = makeThread();
    expect(shallowCompareThreadData(t, t)).toBe(true);
  });

  it('returns false when one is undefined', () => {
    expect(shallowCompareThreadData(makeThread(), undefined)).toBe(false);
    expect(shallowCompareThreadData(undefined, makeThread())).toBe(false);
  });

  it('returns true for deeply-equal threads', () => {
    const a = makeThread();
    const b = makeThread();
    expect(shallowCompareThreadData(a, b)).toBe(true);
  });

  it('returns false when hasUnread differs', () => {
    const a = makeThread({ hasUnread: false });
    const b = makeThread({ hasUnread: true });
    expect(shallowCompareThreadData(a, b)).toBe(false);
  });

  it('returns false when totalReplies differs', () => {
    const a = makeThread({ totalReplies: 1 });
    const b = makeThread({ totalReplies: 2 });
    expect(shallowCompareThreadData(a, b)).toBe(false);
  });

  it('returns false when message count differs', () => {
    const a = makeThread({ messages: [makeMessage() as any] });
    const b = makeThread({ messages: [makeMessage() as any, makeMessage({ id: 'msg-2' }) as any] });
    expect(shallowCompareThreadData(a, b)).toBe(false);
  });

  it('returns false when latest.id differs', () => {
    const a = makeThread({ latest: makeMessage({ id: 'msg-1' }) as any });
    const b = makeThread({ latest: makeMessage({ id: 'msg-2' }) as any });
    expect(shallowCompareThreadData(a, b)).toBe(false);
  });

  it('returns false when tag sets differ', () => {
    const a = makeThread({ latest: makeMessage({ tags: [{ id: 'STARRED', name: 'STARRED', type: 'system' }] }) as any });
    const b = makeThread({ latest: makeMessage({ tags: [] }) as any });
    expect(shallowCompareThreadData(a, b)).toBe(false);
  });

  it('returns true when tag sets are the same regardless of order', () => {
    const a = makeThread({
      latest: makeMessage({ tags: [{ id: 'A', name: 'A', type: 'system' }, { id: 'B', name: 'B', type: 'system' }] }) as any,
    });
    const b = makeThread({
      latest: makeMessage({ tags: [{ id: 'B', name: 'B', type: 'system' }, { id: 'A', name: 'A', type: 'system' }] }) as any,
    });
    expect(shallowCompareThreadData(a, b)).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// setThreadData — equality skip
// ---------------------------------------------------------------------------

describe('setThreadData equality skip', () => {
  it('writes new thread data', () => {
    act(() => useCedarStore.getState().setThreadData('thread-1', makeThread()));
    expect(useCedarStore.getState().threadData['thread-1']).toBeDefined();
  });

  it('skips update when data is identical (prevents re-render)', () => {
    const thread = makeThread();
    act(() => useCedarStore.getState().setThreadData('thread-1', thread));
    const ts1 = useCedarStore.getState().threadData['thread-1']?.lastLoadedAt;

    // Calling again with the same data should be a no-op (lastLoadedAt unchanged)
    act(() => useCedarStore.getState().setThreadData('thread-1', thread));
    const ts2 = useCedarStore.getState().threadData['thread-1']?.lastLoadedAt;

    expect(ts1).toBe(ts2);
  });

  it('updates when hasUnread changes', () => {
    act(() => useCedarStore.getState().setThreadData('thread-1', makeThread({ hasUnread: false })));
    act(() => useCedarStore.getState().setThreadData('thread-1', makeThread({ hasUnread: true })));
    expect(useCedarStore.getState().threadData['thread-1']?.hasUnread).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// setThreadData — temp-draft preservation
// ---------------------------------------------------------------------------

describe('setThreadData user-edited draft preservation', () => {
  it('preserves user-edited local drafts when server data arrives without them', () => {
    const localDraft = makeMessage({
      id: 'temp-draft-123',
      isDraft: true,
      draftSessionId: 'sess-1',
      userEdited: true,
    });
    const initialThread = makeThread({ messages: [makeMessage() as any, localDraft as any] });
    act(() => useCedarStore.getState().setThreadData('thread-1', initialThread));

    // Server sends update without the local draft
    const serverThread = makeThread({
      hasUnread: true, // trigger an update
      messages: [makeMessage() as any],
    });
    act(() => useCedarStore.getState().setThreadData('thread-1', serverThread));

    const messages = useCedarStore.getState().threadData['thread-1']?.messages ?? [];
    expect(messages.some((m) => m.id === 'temp-draft-123')).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// navigateToNextThread / navigateToPreviousThread
// ---------------------------------------------------------------------------

const seedThreadList = (count: number) => {
  const threads = Array.from({ length: count }, (_, i) => ({
    id: `thread-${i + 1}`,
    historyId: null,
  }));
  useCedarStore.setState((s) => ({
    ...s,
    currentThreadList: threads,
    selectedThreadId: threads[0]?.id ?? null,
    focusedIndex: 0,
  }));
};

// ---------------------------------------------------------------------------
// setCurrentThreadList — landing on a folder must leave nothing selected
// ---------------------------------------------------------------------------

describe('setCurrentThreadList', () => {
  const list = [
    { id: 'thread-1', historyId: null, $raw: { conversationId: 'conv-1' } },
    { id: 'thread-2', historyId: null },
  ] as never[];

  it('does NOT select the first thread when nothing is selected', () => {
    act(() => useCedarStore.getState().setCurrentThreadList(list));
    expect(useCedarStore.getState().selectedThreadId).toBeNull();
    expect(useCedarStore.getState().focusedIndex).toBeNull();
  });

  it('does NOT push the first thread\'s conversation into activeConversationId', () => {
    act(() => useCedarStore.getState().setCurrentThreadList(list));
    expect(useCedarStore.getState().activeConversationId).toBeNull();
  });

  it('keeps focusedIndex aligned with an existing selection', () => {
    useCedarStore.setState((s) => ({ ...s, selectedThreadId: 'thread-2' }));
    act(() => useCedarStore.getState().setCurrentThreadList(list));
    expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
    expect(useCedarStore.getState().focusedIndex).toBe(1);
  });

  it('leaves `j` landing on the first row even with nothing selected', () => {
    act(() => {
      useCedarStore.getState().setCurrentThreadList(list);
      useCedarStore.getState().navigateToNextThread();
    });
    expect(useCedarStore.getState().selectedThreadId).toBe('thread-1');
    expect(useCedarStore.getState().focusedIndex).toBe(0);
  });
});

describe('navigateToNextThread', () => {
  it('moves to the next thread', () => {
    seedThreadList(3);
    act(() => useCedarStore.getState().navigateToNextThread());
    expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
    expect(useCedarStore.getState().focusedIndex).toBe(1);
  });

  it('does not go past the last thread', () => {
    seedThreadList(2);
    act(() => {
      useCedarStore.getState().navigateToNextThread();
      useCedarStore.getState().navigateToNextThread(); // attempt to go past end
    });
    expect(useCedarStore.getState().focusedIndex).toBe(1);
    expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
  });

  it('clears bulkSelected', () => {
    seedThreadList(3);
    useCedarStore.setState((s) => ({ ...s, bulkSelected: ['thread-1', 'thread-2'] }));
    act(() => useCedarStore.getState().navigateToNextThread());
    expect(useCedarStore.getState().bulkSelected).toEqual([]);
  });

  it('is a no-op when thread list is empty', () => {
    expect(() => act(() => useCedarStore.getState().navigateToNextThread())).not.toThrow();
  });
});

describe('navigateToPreviousThread', () => {
  it('moves to the previous thread', () => {
    seedThreadList(3);
    useCedarStore.setState((s) => ({ ...s, focusedIndex: 2, selectedThreadId: 'thread-3' }));
    act(() => useCedarStore.getState().navigateToPreviousThread());
    expect(useCedarStore.getState().selectedThreadId).toBe('thread-2');
    expect(useCedarStore.getState().focusedIndex).toBe(1);
  });

  it('does not go before the first thread', () => {
    seedThreadList(3);
    useCedarStore.setState((s) => ({ ...s, focusedIndex: 0, selectedThreadId: 'thread-1' }));
    act(() => useCedarStore.getState().navigateToPreviousThread());
    expect(useCedarStore.getState().focusedIndex).toBe(0);
  });

  it('starts from the last thread when focusedIndex is null', () => {
    seedThreadList(3);
    useCedarStore.setState((s) => ({ ...s, focusedIndex: null, selectedThreadId: null }));
    act(() => useCedarStore.getState().navigateToPreviousThread());
    expect(useCedarStore.getState().focusedIndex).toBe(2);
    expect(useCedarStore.getState().selectedThreadId).toBe('thread-3');
  });
});

// ---------------------------------------------------------------------------
// pushUndo / popUndo — TTL
// ---------------------------------------------------------------------------

describe('pushUndo / popUndo', () => {
  it('pushes and pops an undo entry', () => {
    const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
    act(() => useCedarStore.getState().pushUndo(action));
    const entry = useCedarStore.getState().popUndo();
    expect(entry?.action).toEqual(action);
  });

  it('returns null when no undo entry exists', () => {
    expect(useCedarStore.getState().popUndo()).toBeNull();
  });

  it('clears the entry after popping', () => {
    act(() => useCedarStore.getState().pushUndo({ type: 'markDone', threadIds: ['t'] }));
    useCedarStore.getState().popUndo();
    expect(useCedarStore.getState().undoEntry).toBeNull();
    expect(useCedarStore.getState().undoStack).toHaveLength(0);
  });

  it('returns null and clears entry when TTL (30s) has elapsed', () => {
    const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
    act(() => useCedarStore.getState().pushUndo(action));

    // Backdate the timestamp by more than 30s
    useCedarStore.setState((s) => ({
      ...s,
      undoEntry: { action, timestamp: Date.now() - 31_000 },
      undoStack: [{ action, timestamp: Date.now() - 31_000 }],
    }));

    const result = useCedarStore.getState().popUndo();
    expect(result).toBeNull();
    expect(useCedarStore.getState().undoEntry).toBeNull();
    expect(useCedarStore.getState().undoStack).toHaveLength(0);
  });

  it('supports multiple undos in LIFO order', () => {
    const first = { type: 'markDone' as const, threadIds: ['thread-1'] };
    const second = { type: 'markDone' as const, threadIds: ['thread-2'] };
    const third = { type: 'markDone' as const, threadIds: ['thread-3'] };

    act(() => {
      useCedarStore.getState().pushUndo(first);
      useCedarStore.getState().pushUndo(second);
      useCedarStore.getState().pushUndo(third);
    });

    expect(useCedarStore.getState().popUndo()?.action).toEqual(third);
    expect(useCedarStore.getState().popUndo()?.action).toEqual(second);
    expect(useCedarStore.getState().popUndo()?.action).toEqual(first);
    expect(useCedarStore.getState().popUndo()).toBeNull();
  });

  it('clears redo stack when pushing a new undo action', () => {
    act(() => {
      useCedarStore.getState().pushRedo({ type: 'markDone', threadIds: ['thread-0'] });
      useCedarStore.getState().pushUndo({ type: 'markDone', threadIds: ['thread-1'] });
    });

    expect(useCedarStore.getState().redoStack).toHaveLength(0);
  });

  it('preserves redo stack when pushUndo clearRedo is false', () => {
    act(() => {
      useCedarStore.getState().pushRedo({ type: 'markDone', threadIds: ['thread-0'] });
      useCedarStore.getState().pushUndo(
        { type: 'markDone', threadIds: ['thread-1'] },
        { clearRedo: false },
      );
    });

    expect(useCedarStore.getState().redoStack).toHaveLength(1);
  });
});

describe('pushRedo / popRedo', () => {
  it('pushes and pops a redo entry', () => {
    const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
    act(() => useCedarStore.getState().pushRedo(action));
    const entry = useCedarStore.getState().popRedo();
    expect(entry?.action).toEqual(action);
  });

  it('returns null when no redo entry exists', () => {
    expect(useCedarStore.getState().popRedo()).toBeNull();
  });

  it('returns null after redo TTL expires', () => {
    const action = { type: 'markDone' as const, threadIds: ['thread-1'] };
    act(() => useCedarStore.getState().pushRedo(action));

    useCedarStore.setState((s) => ({
      ...s,
      redoStack: [{ action, timestamp: Date.now() - 31_000 }],
    }));

    const entry = useCedarStore.getState().popRedo();
    expect(entry).toBeNull();
    expect(useCedarStore.getState().redoStack).toHaveLength(0);
  });
});

// ---------------------------------------------------------------------------
// toggleStar / markAsRead / toggleImportant
// ---------------------------------------------------------------------------

const seedThread = (id = 'thread-1', extra: Partial<ThreadData> = {}) => {
  useCedarStore.setState((s) => ({
    ...s,
    threadData: {
      [id]: makeThread({ id, ...extra }),
    },
  }));
};

describe('toggleStar', () => {
  it('adds STARRED label when starred=true', () => {
    seedThread();
    act(() => useCedarStore.getState().toggleStar(['thread-1'], true));
    expect(useCedarStore.getState().threadData['thread-1']?.labels).toContainEqual(
      expect.objectContaining({ name: 'STARRED' }),
    );
  });

  it('removes STARRED label when starred=false', () => {
    seedThread('thread-1', {
      labels: [{ id: 'STARRED', name: 'STARRED' }],
    });
    act(() => useCedarStore.getState().toggleStar(['thread-1'], false));
    const labels = useCedarStore.getState().threadData['thread-1']?.labels ?? [];
    expect(labels.some((l) => l.name === 'STARRED')).toBe(false);
  });

  it('does not duplicate STARRED label on repeated calls', () => {
    seedThread();
    act(() => {
      useCedarStore.getState().toggleStar(['thread-1'], true);
      useCedarStore.getState().toggleStar(['thread-1'], true);
    });
    const labels = useCedarStore.getState().threadData['thread-1']?.labels ?? [];
    expect(labels.filter((l) => l.name === 'STARRED')).toHaveLength(1);
  });

  it('is a no-op for unknown thread IDs', () => {
    expect(() =>
      act(() => useCedarStore.getState().toggleStar(['does-not-exist'], true)),
    ).not.toThrow();
  });
});

describe('markAsRead', () => {
  it('sets hasUnread to false when read=true', () => {
    seedThread('thread-1', { hasUnread: true });
    act(() => useCedarStore.getState().markAsRead(['thread-1'], true));
    expect(useCedarStore.getState().threadData['thread-1']?.hasUnread).toBe(false);
  });

  it('sets hasUnread to true when read=false', () => {
    seedThread('thread-1', { hasUnread: false });
    act(() => useCedarStore.getState().markAsRead(['thread-1'], false));
    expect(useCedarStore.getState().threadData['thread-1']?.hasUnread).toBe(true);
  });
});

describe('toggleImportant', () => {
  it('adds IMPORTANT label when important=true', () => {
    seedThread();
    act(() => useCedarStore.getState().toggleImportant(['thread-1'], true));
    expect(useCedarStore.getState().threadData['thread-1']?.labels).toContainEqual(
      expect.objectContaining({ name: 'IMPORTANT' }),
    );
  });

  it('removes IMPORTANT label when important=false', () => {
    seedThread('thread-1', {
      labels: [{ id: 'IMPORTANT', name: 'IMPORTANT' }],
    });
    act(() => useCedarStore.getState().toggleImportant(['thread-1'], false));
    const labels = useCedarStore.getState().threadData['thread-1']?.labels ?? [];
    expect(labels.some((l) => l.name === 'IMPORTANT')).toBe(false);
  });
});

// ---------------------------------------------------------------------------
// populateThreadMetadata — placeholder creation
// ---------------------------------------------------------------------------

describe('populateThreadMetadata', () => {
  it('creates a skeleton thread when threadData does not exist', () => {
    act(() =>
      useCedarStore.getState().populateThreadMetadata('thread-new', {
        hasUnread: true,
        totalReplies: 2,
        labels: [],
        preview: {
          sender: { email: '<email>', name: 'Alice' },
          subject: 'Hello',
          receivedOn: '2026-01-01T00:00:00.000Z',
          to: [],
          snippet: '',
        },
      }),
    );
    const thread = useCedarStore.getState().threadData['thread-new'];
    expect(thread).toBeDefined();
    expect(thread?.hasUnread).toBe(true);
  });

  it('sets latest from preview without placeholder messages for new threads', () => {
    act(() =>
      useCedarStore.getState().populateThreadMetadata('thread-ph', {
        hasUnread: false,
        totalReplies: 3,
        labels: [],
        preview: {
          sender: { email: '<email>' },
          subject: 'Test',
          receivedOn: '2026-01-01T00:00:00.000Z',
          to: [],
          snippet: '',
        },
      }),
    );
    const thread = useCedarStore.getState().threadData['thread-ph'];
    expect(thread?.messages).toHaveLength(0);
    expect(thread?.latest?.sender.email).toBe('<email>');
    expect(thread?.latest?.subject).toBe('Test');
    expect(thread?.totalReplies).toBe(3);
  });

  it('appends placeholders to an existing thread that is missing messages', () => {
    seedThread('thread-1', { totalReplies: 1 });
    act(() =>
      useCedarStore.getState().populateThreadMetadata('thread-1', {
        hasUnread: false,
        totalReplies: 3,
        labels: [],
        preview: {
          sender: { email: '<email>' },
          subject: 'Hello',
          receivedOn: '2026-01-01T00:00:00.000Z',
          to: [],
          snippet: '',
        },
      }),
    );
    expect(useCedarStore.getState().threadData['thread-1']?.messages).toHaveLength(3);
  });

  it('does not add placeholders when messages count already meets totalReplies', () => {
    seedThread('thread-1', { totalReplies: 1 });
    act(() =>
      useCedarStore.getState().populateThreadMetadata('thread-1', {
        hasUnread: false,
        totalReplies: 1,
        labels: [],
      }),
    );
    expect(useCedarStore.getState().threadData['thread-1']?.messages).toHaveLength(1);
  });
});