playbookFileTree.test.tsx5.3 KBView on GitHub
/**
 * A conversation type's playbook, as a file system.
 *
 * Three things, each of which the flat one-scope list this replaced got wrong:
 *
 *  1. THE PLAYBOOK IS PINNED FIRST. It is the document the screen is about and the only one
 *     Cedar runs; left to the tree's own order it moved every time another file was opened.
 *  2. USER AND ORG ARE THE ROOTS. Two workspaces, two sets of permissions — who a playbook
 *     file applies to is the most load-bearing fact about it, and a flat list rooted in one
 *     of them said it quietly.
 *  3. NEITHER ROOT LISTS ITS OWN PLAYBOOK.md. The pinned row opens the COMBINED editor; the
 *     two halves are one document, and listing them invites editing a half.
 */

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

/**
 * A row's test id sits on its wrapper; the activator is the `<button>` inside it (the row
 * renders its columns as siblings of the hit area — see `FileListRow`).
 */
const clickRow = (testId: string) => {
  const button = screen.getByTestId(testId).querySelector('button');
  if (!button) throw new Error(`row "${testId}" has no activator`);
  fireEvent.click(button);
};

const mockUseFileTree = jest.fn();
jest.mock('@/modules/conversations/components/files/ConversationFileTree', () => ({
  useFileTree: (options: unknown) => mockUseFileTree(options),
}));
jest.mock('@/modules/auth/utils/auth-client', () => ({
  useSession: () => ({ data: { user: { id: 'user_1' } } }),
}));
jest.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: { id: 'org_1' } }) }));
jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({ organisation: { getMyOrg: { queryOptions: () => ({}) } } }),
}));

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

const node = (over: Partial<{ id: string; documentType: string; path: string; title: string }>) => ({
  id: 'n1',
  parentId: null,
  documentType: 'document',
  path: 'user/playbooks/aop_1/notes.md',
  title: 'Notes',
  ...over,
});

/** One row per tree, so each scope folder has something under it. */
function treeWith(rows: ReturnType<typeof node>[]) {
  return {
    rows: rows.map((n) => ({ node: n, depth: 0, isFolder: false, expanded: false })),
    rootMissing: false,
    toggleExpand: jest.fn(),
    isLoading: false,
  };
}

beforeEach(() => {
  mockUseFileTree.mockReset();
  mockUseFileTree.mockReturnValue(treeWith([node({})]));
});

describe('PlaybookFileTree', () => {
  it('pins the Playbook as the very first row, above both scope folders', () => {
    render(
      <PlaybookFileTree
        aopId="aop_1"
        orgAopId="org_aop_1"
        onOpenPlaybook={jest.fn()}
        onOpenFile={jest.fn()}
      />,
    );

    const order = Array.from(
      document.querySelectorAll('[data-testid="playbook-row"],[data-testid^="playbook-scope-"]'),
    ).map((n) => n.getAttribute('data-testid'));
    expect(order).toEqual(['playbook-row', 'playbook-scope-user', 'playbook-scope-org']);
  });

  it('roots each folder in its OWN workspace, at that workspace’s playbook path', () => {
    render(
      <PlaybookFileTree
        aopId="aop_1"
        orgAopId="org_aop_1"
        onOpenPlaybook={jest.fn()}
        onOpenFile={jest.fn()}
      />,
    );

    const calls = mockUseFileTree.mock.calls.map(([o]) => [o.scope, o.rootPath]);
    expect(calls).toEqual([
      [{ type: 'user', id: 'user_1' }, 'user/playbooks/aop_1'],
      [{ type: 'org', id: 'org_1' }, 'organisation/playbooks/org_aop_1'],
    ]);
  });

  it('hides each copy’s own PLAYBOOK.md — the pinned row is the combined one', () => {
    render(
      <PlaybookFileTree
        aopId="aop_1"
        orgAopId="org_aop_1"
        onOpenPlaybook={jest.fn()}
        onOpenFile={jest.fn()}
      />,
    );

    for (const [options] of mockUseFileTree.mock.calls) {
      expect(options.includeNode(node({ documentType: 'playbook' }))).toBe(false);
      expect(options.includeNode(node({ documentType: 'document' }))).toBe(true);
    }
  });

  it('drops the Org root entirely when the team has no copy', () => {
    render(
      <PlaybookFileTree aopId="aop_1" onOpenPlaybook={jest.fn()} onOpenFile={jest.fn()} />,
    );

    expect(screen.getByTestId('playbook-scope-user')).toBeInTheDocument();
    expect(screen.queryByTestId('playbook-scope-org')).not.toBeInTheDocument();
  });

  it('opens the combined playbook from the pinned row, and a file from a tree row', () => {
    const onOpenPlaybook = jest.fn();
    const onOpenFile = jest.fn();
    mockUseFileTree.mockReturnValue(treeWith([node({ id: 'doc_9' })]));

    render(
      <PlaybookFileTree
        aopId="aop_1"
        onOpenPlaybook={onOpenPlaybook}
        onOpenFile={onOpenFile}
      />,
    );

    clickRow('playbook-row');
    expect(onOpenPlaybook).toHaveBeenCalled();

    clickRow('playbook-file-doc_9');
    expect(onOpenFile).toHaveBeenCalledWith(expect.objectContaining({ id: 'doc_9' }));
  });

  it('nests a scope’s files one level under its root row', () => {
    render(
      <PlaybookFileTree aopId="aop_1" onOpenPlaybook={jest.fn()} onOpenFile={jest.fn()} />,
    );

    // The scope row sits at the list's own indent; its contents step in by one.
    expect(screen.getByTestId('playbook-scope-user').dataset.depth).toBe('0');
    expect(screen.getByTestId('playbook-file-n1').dataset.depth).toBe('1');
  });
});