embeddedTabs.test.tsx3.6 KBView on GitHub
import type { MessageThread } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';

/**
 * Phase 4 (design: chat-thread-store) — EmbeddedCedarChat's tab bar is now derived
 * purely from the store: openTabIds = getThreadsByView('pinned') ∪ activeThread, and
 * per-tab processing/finished come from thread.status. Rendering the full 1300-line
 * component in jsdom would need the entire tRPC/canvas/portal provider stack, so we
 * assert the real derivation the component performs — the logic that actually changed.
 */
const thread = (over: Partial<MessageThread> & Pick<MessageThread, 'id'>): MessageThread => ({
  messages: [],
  ...over,
});

// Mirrors EmbeddedCedarChat's openTabIds useMemo.
const deriveOpenTabIds = (pinnedIds: string[], mainThreadId: string): string[] => {
  const ids = [...pinnedIds];
  if (mainThreadId && !ids.includes(mainThreadId)) ids.push(mainThreadId);
  return ids;
};

describe('EmbeddedCedarChat tab model — derived from views + status', () => {
  beforeEach(() => {
    useCedarStore.setState((state) => ({
      ...state,
      threadMap: {
        t1: thread({ id: 't1', name: 'Adobe', pinned: true, status: 'streaming' }),
        t2: thread({ id: 't2', name: 'Numeral', pinned: true, status: 'finished' }),
        // An unpinned, active thread — should still appear as a tab via the union.
        t3: thread({ id: 't3', name: 'Scratch', status: 'idle' }),
      },
      mainThreadId: 't3',
      activeThreadId: 't3',
      messages: [],
    }));
  });

  it('openTabIds = pinned view unioned with the active thread', () => {
    const pinnedIds = useCedarStore.getState().getThreadsByView('pinned').map((t) => t.id);
    const openTabIds = deriveOpenTabIds(pinnedIds, useCedarStore.getState().mainThreadId);
    expect(openTabIds).toEqual(['t1', 't2', 't3']);
  });

  it('active pinned thread is not duplicated in the tab set', () => {
    useCedarStore.getState().selectThread('t1'); // t1 is pinned + now active
    const pinnedIds = useCedarStore.getState().getThreadsByView('pinned').map((t) => t.id);
    const openTabIds = deriveOpenTabIds(pinnedIds, useCedarStore.getState().mainThreadId);
    expect(openTabIds).toEqual(['t1', 't2']);
  });

  it('per-tab processing/finished markers come from thread.status (finished hidden on active tab)', () => {
    const { threadMap, mainThreadId } = useCedarStore.getState();
    const markerFor = (tid: string) => {
      const status = threadMap[tid]?.status;
      return {
        isTabProcessing: status === 'streaming',
        isTabFinished: status === 'finished' && tid !== mainThreadId,
      };
    };
    // t1 streaming → processing
    expect(markerFor('t1')).toEqual({ isTabProcessing: true, isTabFinished: false });
    // t2 finished and NOT active → finished badge
    expect(markerFor('t2')).toEqual({ isTabProcessing: false, isTabFinished: true });
    // t3 idle active → neither
    expect(markerFor('t3')).toEqual({ isTabProcessing: false, isTabFinished: false });
  });

  it('selecting a finished tab clears its badge (setThreadStatus idle)', () => {
    // Simulate onValueChange: switch + clear finished
    useCedarStore.getState().switchThread('t2');
    useCedarStore.getState().setThreadStatus('t2', 'idle');
    expect(useCedarStore.getState().threadMap['t2'].status).toBe('idle');
  });

  it('close tab = unpinThread (drops it from the pinned view)', () => {
    useCedarStore.getState().unpinThread('t1');
    const pinnedIds = useCedarStore.getState().getThreadsByView('pinned').map((t) => t.id);
    expect(pinnedIds).toEqual(['t2']);
  });
});