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();
});
});