playbookAopSection.test.tsx5.5 KBView on GitHub
/**
 * One conversation type on the Playbooks screen.
 *
 * What this pins, all of it something the previous coloured-card version got wrong:
 *
 *  1. THE FILES ARE THE APP'S FILE UI — `PlaybookFileTree`, built from the Files kit's rows.
 *     The old card drew a private tree, so the same document looked different here than in
 *     the Brain's own explorer.
 *  2. THE PLAYBOOK ROW OPENS THE COMPOSITE EDITOR — user and org merged. There is no route
 *     from this card to either half on its own: the generic editor shows one of them and a
 *     save would overwrite the merge.
 *  3. THE COLOUR IS A CONTROL, not a wash. Picking one writes it; it does not tint a box.
 */

import { fireEvent, render, screen } from '@testing-library/react';

const mockNavigate = jest.fn();
jest.mock('react-router', () => ({ useNavigate: () => mockNavigate }));

const mockUpdateAop = jest.fn();
const mockSeedPlaybook = jest.fn();
jest.mock('@tanstack/react-query', () => ({
  useMutation: (options: { mutationKey?: unknown }) => ({
    // The two mutations are told apart by the options object the component spreads in,
    // which carries the tRPC mutation key. Simpler than mocking the whole tRPC proxy.
    mutateAsync: (input: unknown) =>
      Promise.resolve(
        String((options as { __name?: string }).__name) === 'seed'
          ? mockSeedPlaybook(input)
          : mockUpdateAop(input),
      ),
    isPending: false,
  }),
  useQueryClient: () => ({ invalidateQueries: jest.fn() }),
}));
jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    aop: {
      updateAop: { mutationOptions: () => ({ __name: 'update' }) },
      seedPlaybookFiles: { mutationOptions: () => ({ __name: 'seed' }) },
      listAopsForUser: { queryKey: () => ['aop.list'] },
    },
    files: { listChildren: { queryKey: () => ['files.listChildren'] } },
    documents: { getDoc: { queryKey: () => ['documents.getDoc'] } },
  }),
}));

// The tree has its own test and drags in the whole file stack. Here it is a sentinel that
// reports the ids it was rooted with, and offers the two opens the card wires into it.
jest.mock('@/modules/brain/components/PlaybookFileTree', () => ({
  PlaybookFileTree: ({
    aopId,
    orgAopId,
    onOpenPlaybook,
    onOpenFile,
  }: {
    aopId: string;
    orgAopId?: string | null;
    onOpenPlaybook: () => void;
    onOpenFile: (node: { id: string }) => void;
  }) => (
    <div data-testid="playbook-file-tree" data-aop={aopId} data-org-aop={orgAopId ?? ''}>
      <button type="button" data-testid="open-playbook" onClick={onOpenPlaybook}>
        open playbook
      </button>
      <button type="button" data-testid="open-file" onClick={() => onOpenFile({ id: 'doc_x' })}>
        open file
      </button>
    </div>
  ),
}));
jest.mock('@/modules/brain/components/AopManageDialogs', () => ({
  AopIdentityDialog: () => null,
  AopDeleteDialog: () => null,
}));
jest.mock('@/components/ui/SexyColourPicker', () => ({
  ColorPickerPopover: ({ onColorSelect }: { onColorSelect?: (c: string) => void }) => (
    <button type="button" data-testid="colour-picker" onClick={() => onColorSelect?.('#ff0000')}>
      colour
    </button>
  ),
}));

import { PlaybookAopSection } from '@/modules/brain/components/PlaybookAopSection';

const AOP = { id: 'aop_1', name: 'Deals', color: '#22c55e', orgAopId: 'org_aop_1', isNoOp: false };

beforeEach(() => {
  mockNavigate.mockClear();
  mockUpdateAop.mockClear();
  mockSeedPlaybook.mockClear();
});

describe('PlaybookAopSection', () => {
  it('names the type in the FOREGROUND colour — the swatch carries the AOP colour', () => {
    render(<PlaybookAopSection aop={AOP} />);

    const title = screen.getByRole('heading', { name: 'Deals' });
    // A coloured heading on a white card reads as a link or a status; the dot beside it is
    // where the AOP's colour belongs.
    expect(title).not.toHaveStyle({ color: '#22c55e' });
    // One notch above body text — a card header, not a page heading.
    expect(title.className).toContain('text-lg');
  });

  it('roots its file tree at both of the type’s ids', () => {
    render(<PlaybookAopSection aop={AOP} />);

    const tree = screen.getByTestId('playbook-file-tree');
    expect(tree.dataset.aop).toBe('aop_1');
    expect(tree.dataset.orgAop).toBe('org_aop_1');
  });

  it('opens the COMBINED playbook — never one half of it', () => {
    render(<PlaybookAopSection aop={AOP} />);

    fireEvent.click(screen.getByTestId('open-playbook'));
    // No `org=1`: there is one playbook here, and the composite editor merges both copies.
    expect(mockNavigate).toHaveBeenCalledWith('/agents/playbook?aop=aop_1');
  });

  it('opens every other file in the Brain explorer', () => {
    render(<PlaybookAopSection aop={AOP} />);

    fireEvent.click(screen.getByTestId('open-file'));
    expect(mockNavigate).toHaveBeenCalledWith('/brain/knowledge?documentId=doc_x');
  });

  it('writes the colour when one is picked — it is a control, not a swatch', () => {
    render(<PlaybookAopSection aop={AOP} />);

    fireEvent.click(screen.getByTestId('colour-picker'));
    expect(mockUpdateAop).toHaveBeenCalledWith({ id: 'aop_1', name: 'Deals', color: '#ff0000' });
  });

  it('shows no file list while the type is inactive — its folder is not seeded yet', () => {
    render(<PlaybookAopSection aop={{ ...AOP, isNoOp: true }} />);

    expect(screen.queryByTestId('playbook-file-tree')).not.toBeInTheDocument();
    // The switch that turns it on is still there, and is what seeds the files.
    expect(screen.getByRole('switch', { name: /AI executions for Deals/i })).toBeInTheDocument();
  });
});