unifiedInboxOrder.test.ts3.9 KBView on GitHub
import { act } from '@testing-library/react';

import { useCedarStore } from '@/modules/store';
import { selectMergedFeed } from '@/modules/inbox/store/inboxSlice';
import type { InboxItem } from '@/modules/inbox/types';

/**
 * `getUnifiedInboxList()` is the list every selection surface indexes into — keyboard nav,
 * shift-range select, the bulk actions and `getActiveListItems`. It must therefore return the
 * RENDERED order, which since Phase 8 is the merged order cut at the watermark: a row the
 * store has loaded but is holding back is not on screen, so selecting or stepping onto it
 * would move the cursor somewhere the rep cannot see.
 */
const item = (channel: 'email' | 'slack', id: string, sortedAt: string): InboxItem =>
  ({
    id: channel === 'email' ? `email:${id}` : id,
    channel,
    sortedAt,
    counterpart: { name: id },
    snippet: '',
    unread: false,
    starred: false,
    hasDraft: false,
    done: false,
    labelIds: [],
    participantCount: 1,
    ref:
      channel === 'email'
        ? { kind: 'email', threadId: id, connectionId: 'conn-1' }
        : { kind: 'slack', conversationId: '', slackChannelId: id, workspaceId: 'W1' },
  }) as unknown as InboxItem;

beforeEach(() => {
  act(() => useCedarStore.getState().setChannelFeeds({}));
});

describe('getUnifiedInboxList', () => {
  it('is the merged order, interleaved across channels by time', () => {
    act(() =>
      useCedarStore.getState().setChannelFeeds({
        email: {
          items: [
            item('email', 'a', '2026-08-30T10:00:00.000Z'),
            item('email', 'b', '2026-08-30T08:00:00.000Z'),
          ],
          settled: true,
          exhausted: true,
        },
        slack: {
          items: [item('slack', 'slack:1', '2026-08-30T09:00:00.000Z')],
          settled: true,
          exhausted: true,
        },
      }),
    );

    // Email rows are keyed by their BARE thread id — the selection id, which is what
    // `selectedThreadId`, `bulkSelected` and `data-thread-id` all speak.
    expect(useCedarStore.getState().getUnifiedInboxList().map((row) => row.id)).toEqual([
      'a',
      'slack:1',
      'b',
    ]);
  });

  it('stops at the watermark, so nothing selectable is off screen', () => {
    act(() =>
      useCedarStore.getState().setChannelFeeds({
        email: {
          items: [item('email', 'a', '2026-08-30T10:00:00.000Z')],
          settled: true,
          exhausted: false,
        },
        slack: {
          items: [item('slack', 'slack:held', '2026-08-30T06:00:00.000Z')],
          settled: true,
          exhausted: false,
        },
      }),
    );

    const state = useCedarStore.getState();
    expect(state.getUnifiedInboxList().map((row) => row.id)).toEqual(['a']);
    expect(selectMergedFeed(state).held).toBe(1);
    // Held, not dropped: the row is still loaded, so a deep link to it still resolves.
    expect(state.getInboxItem('slack:held')).toBeDefined();
  });

  it('is empty while any participating channel is still resolving', () => {
    act(() =>
      useCedarStore.getState().setChannelFeeds({
        email: {
          items: [item('email', 'a', '2026-08-30T10:00:00.000Z')],
          settled: true,
          exhausted: true,
        },
        slack: { items: [], settled: false, exhausted: false },
      }),
    );

    expect(useCedarStore.getState().getUnifiedInboxList()).toEqual([]);
  });

  it('returns the same array identity until the feed changes', () => {
    const feeds = {
      email: {
        items: [item('email', 'a', '2026-08-30T10:00:00.000Z')],
        settled: true,
        exhausted: true,
      },
    };
    act(() => useCedarStore.getState().setChannelFeeds(feeds));

    // The merge sorts, and `useMailNavigation` takes this list as a dependency — recomputing
    // it per render would re-sort the feed on every keystroke and churn the effect.
    expect(useCedarStore.getState().getUnifiedInboxList()).toBe(
      useCedarStore.getState().getUnifiedInboxList(),
    );
  });
});