calendarCanvasKeepsChat.test.tsx7.0 KBView on GitHub
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import React from 'react';

/**
 * ⇧Q is the /calendar PAGE dropped over the route you were on: the grid takes the MAIN column and
 * the column beside it becomes the calendar's own rail — not one overlay painted across both.
 *
 * The overlay used to span the whole shell, which buried the chat and, with it, the event editor
 * the chat column swaps in for a draft event: you could open the calendar from anywhere and then
 * had no way to finish creating an event (adding a Google Meet especially) without closing it.
 * Then it kept the chat but left the ROUTE's surface in it, so /home answered ⇧Q by squeezing the
 * home hero into a sidebar beside the grid.
 */
jest.mock('@/components/layout/ChatColumn', () => ({
  ChatColumn: () => {
    const R = require('react');
    return R.createElement('div', { 'data-testid': 'chat' }, 'CHAT');
  },
}));
jest.mock('@/modules/calendar/components/CalendarSidebar', () => ({
  CalendarSidebar: () => {
    const R = require('react');
    return R.createElement('div', { 'data-testid': 'event-editor' }, 'EVENT EDITOR');
  },
}));
jest.mock('@/components/ui/GlobalCanvas', () => ({
  // Gated like the real one, so "is a canvas on screen" means the same thing here as in the app.
  GlobalCanvas: () => {
    const R = require('react');
    const { useCedarStore } = require('@/store/CedarStore');
    const open = useCedarStore((s: { isGlobalCanvasOpen: boolean }) => s.isGlobalCanvasOpen);
    return open ? R.createElement('div', { 'data-testid': 'global-canvas' }, 'CALENDAR') : null;
  },
}));
jest.mock('@/modules/ux/layout/useRouteChatThread', () => ({
  useRouteChatThread: () => {},
}));

import { selectCalendarRail } from '@/modules/ux/layout/selectCalendarRail';
import { CALENDAR_RAIL_WIDTH } from '@/components/layout/widthRegistry';
import { DEFAULT_THREAD_ID } from '@/store/messages/MessageTypes';
import { AppShell } from '@/components/layout/AppShell';
import { useCedarStore } from '@/store/CedarStore';

/** /home at rest — the chat-dominant arrangement, where the chat is the only column. */
function setHomeAtRest(over: Partial<Record<string, unknown>> = {}) {
  act(() => {
    useCedarStore.setState((s) => ({
      ...s,
      navigation: { page: 'home' },
      mainThreadId: DEFAULT_THREAD_ID,
      activeThreadId: DEFAULT_THREAD_ID,
      selectedThreadId: null,
      threadMap: {
        [DEFAULT_THREAD_ID]: {
          id: DEFAULT_THREAD_ID,
          lastLoaded: new Date().toISOString(),
          messages: [],
          selectedArtifact: null,
        },
      },
      isGlobalCanvasOpen: false,
      activeCanvasId: null,
      draftCalendarEvent: null,
      ...over,
    }));
  });
}

const renderShell = () =>
  render(
    <QueryClientProvider client={queryClient}>
      <MemoryRouter initialEntries={['/home']}>
        <AppShell>
          <div data-testid="route-content">route-content</div>
        </AppShell>
      </MemoryRouter>
    </QueryClientProvider>,
  );

/**
 * The tree under test reaches `useCalendarCanvasOverlay`, which holds a query client so it
 * can refetch the week on the way into the calendar overlay. The app always has the provider
 * above these components; without one here the hook throws before anything renders.
 *
 * `retry: false` so a stray query fails fast instead of holding the test open.
 */
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

describe('the calendar canvas and the chat', () => {
  it('keeps the chat on screen beside the calendar', () => {
    setHomeAtRest();
    renderShell();
    expect(screen.queryByTestId('global-canvas')).toBeNull();

    act(() => {
      useCedarStore.setState((s) => ({
        ...s,
        isGlobalCanvasOpen: true,
        activeCanvasId: 'calendar',
      }));
    });

    expect(screen.getByTestId('global-canvas')).toBeInTheDocument();
    expect(screen.getByTestId('chat')).toBeInTheDocument();
  });

  it('leaves the chat alone while the grid is up — the draft is composed on its own block', () => {
    setHomeAtRest({ isGlobalCanvasOpen: true, activeCanvasId: 'calendar' });
    renderShell();

    act(() => {
      useCedarStore.setState((s) => ({
        ...s,
        draftCalendarEvent: { summary: 'Intro call', start: {}, end: {} },
      }));
    });

    // With the grid on screen the draft has a block of its own, so the composer is pinned to
    // it (EventComposerPopover) and the chat column is not swapped out to hold a form about
    // something already on screen.
    expect(screen.queryByTestId('event-editor')).not.toBeInTheDocument();
    expect(screen.getByTestId('chat')).toBeInTheDocument();
    expect(screen.getByTestId('global-canvas')).toBeInTheDocument();
  });

  it('swaps in the editor panel for a draft raised with no grid to pin to', () => {
    // A follow-up scheduled from a thread, or the chat's proposed-event card: there is no block
    // on screen to anchor a popover to, so the chat column still carries the full editor.
    setHomeAtRest({ isGlobalCanvasOpen: false, activeCanvasId: null });
    renderShell();

    act(() => {
      useCedarStore.setState((s) => ({
        ...s,
        draftCalendarEvent: { summary: 'Intro call', start: {}, end: {} },
      }));
    });

    expect(screen.getByTestId('event-editor')).toBeInTheDocument();
  });

  it('does not drag the agent page up behind the calendar', () => {
    // /home renders nothing into the context column at rest, so borrowing that column for the
    // calendar must not start mounting the route's content there.
    setHomeAtRest({ isGlobalCanvasOpen: true, activeCanvasId: 'calendar' });
    renderShell();
    expect(screen.queryByTestId('route-content')).toBeNull();
  });

  it('hands the chat column to the calendar rail while the grid is down', () => {
    // ⇧Q is the /calendar PAGE dropped over the route you were on, so the column beside the grid
    // is the calendar's own rail (month · "Meet with" · calendars) pinned to the month grid's
    // width — not the home hero squeezed into a sidebar, which is what it used to be.
    setHomeAtRest();
    renderShell();
    expect(selectCalendarRail(useCedarStore.getState())).toBe(false);

    act(() => {
      useCedarStore.setState((s) => ({
        ...s,
        isGlobalCanvasOpen: true,
        activeCanvasId: 'calendar',
      }));
    });

    expect(selectCalendarRail(useCedarStore.getState())).toBe(true);
    expect(screen.getByTestId('chat').closest('[style*="width"]')).toHaveStyle({
      width: `${CALENDAR_RAIL_WIDTH}px`,
    });
  });

  it('leaves every other canvas covering the whole shell', () => {
    setHomeAtRest({ isGlobalCanvasOpen: true, activeCanvasId: 'canvas_crm_filter' });
    renderShell();
    // Still exactly one canvas mount, and the chat still renders behind it (the overlay covers
    // it visually — that is the untouched behaviour for non-calendar canvases).
    expect(screen.getAllByTestId('global-canvas')).toHaveLength(1);
  });
});