GraphInspector.test.tsx10.3 KBView on GitHub
/**
 * The canvas's top-right surface.
 *
 * Mounted directly rather than through the canvas: the point of this component is WHAT IT SHOWS
 * for a given selection, and a React Flow harness would add a provider and a graph read around
 * that one assertion.
 */

import { render, screen } from '@testing-library/react';
import type { GraphEdge, GraphNode, GraphSchema } from '@zero/server/graph';

import { GraphInspector } from '@/modules/graph/GraphInspector';

/**
 * The document the selected node points at, and whether the card asked for it.
 *
 * `mock`-prefixed because a `jest.mock` factory is hoisted above every other binding in the
 * file and may only close over names starting with `mock`.
 */
let mockDoc: Record<string, unknown> | null = null;
let mockDocEnabled: boolean | undefined;

jest.mock('@/providers/query-provider', () => ({
  useTRPC: () => ({
    documents: {
      getDoc: {
        queryOptions: (input: { documentId: string }) => ({
          queryKey: ['documents.getDoc', input],
        }),
      },
    },
  }),
}));

// The body is the real `<Document />` — TipTap bound to a Y.Doc through the provider registry.
// Mounting it here would put an editor, a websocket-ish provider and IndexedDB behind every
// assertion in this file, none of which is what this file is about.
jest.mock('@/modules/documents/document', () => ({
  Document: ({ documentId }: { documentId: string }) => (
    <div data-testid="document-body">{documentId}</div>
  ),
}));

jest.mock('@tanstack/react-query', () => ({
  useQuery: (options: { enabled?: boolean }) => {
    mockDocEnabled = options.enabled;
    return { data: options.enabled === false ? undefined : mockDoc, isLoading: false };
  },
}));

const SCHEMA: GraphSchema = {
  version: 1,
  visibility: 'team',
  nodeSchema: {
    identityField: 'person',
    fields: [
      { key=[redacted], label: 'Person', type: 'person' },
      { key=[redacted], label: 'Photo', type: 'url', from: 'person.photoUrl' },
      { key=[redacted], label: 'Title', type: 'text', from: 'person.title' },
      { key=[redacted], label: 'LinkedIn', type: 'url', from: 'person.linkedinUrl' },
      { key=[redacted], label: 'Stance', type: 'select', options: ['supporter', 'skeptic'] },
    ],
  },
  edgeKinds: [
    { key=[redacted], label: 'Reports to', fields: [{ key=[redacted], label: 'Since', type: 'date' }] },
  ],
};

const MARCUS: GraphNode = {
  id: 'n1',
  documentId: 'doc_marcus',
  title: 'Marcus Lee',
  fields: { stance: 'skeptic', person: '<email>' },
  derived: {
    title: 'cached CTO',
    photo: 'https://cdn.example/marcus.jpg',
    linkedin: 'https://linkedin.com/in/marcus',
  },
};

const EDGE: GraphEdge = {
  id: 'n1:n2:reports_to',
  source: 'n1',
  target: 'n2',
  kind: 'reports_to',
  label: 'Boss/Manager',
  description: 'Solid line since the 2025 reorg.',
  fields: { since: '2025-03' },
};

const NODES: Record<string, GraphNode> = {
  n1: MARCUS,
  n2: { id: 'n2', documentId: 'doc_paula', title: 'Paula Rodgers', fields: {} },
};

function mount(over: Partial<React.ComponentProps<typeof GraphInspector>> = {}) {
  return render(
    <GraphInspector
      selection={{ kind: 'node', nodeId: 'n1' }}
      graphName="Acme org chart"
      schema={SCHEMA}
      nodes={NODES}
      edges={{ [EDGE.id]: EDGE }}
      onClose={jest.fn()}
      {...over}
    />,
  );
}

beforeEach(() => {
  mockDoc = {
    title: 'Marcus Lee',
    path: 'contact/marcus/profile',
    content: '# Notes\n\nMet at the Q3 review. Owns the migration.',
    metadata: { fields: { timezone: 'CET' } },
  };
  mockDocEnabled = undefined;
});

describe('nothing selected', () => {
  it('renders nothing at all', () => {
    const { container } = mount({ selection: null });
    expect(container).toBeEmptyDOMElement();
  });
});

describe('a selected NODE shows the document itself', () => {
  it("heads the panel with the document's title — not the node id, not the path", () => {
    mount();
    expect(screen.getByRole('heading', { name: 'Marcus Lee' })).toBeInTheDocument();
    expect(screen.queryByText('contact/marcus/profile')).not.toBeInTheDocument();
    expect(screen.queryByText('n1')).not.toBeInTheDocument();
  });

  it('binds the REAL document, editable — not a rendered copy of its markdown', () => {
    // One Y.Doc, two views: typing here and typing on the document page are the same
    // keystrokes, which is the whole reason this is `<Document />` and not a markdown render.
    mount();
    expect(screen.getByTestId('document-body')).toHaveTextContent('doc_marcus');
  });

  it('can be dragged wider, and the handle says so', () => {
    mount();
    expect(screen.getByRole('separator', { name: 'Resize panel' })).toHaveClass(
      'cursor-col-resize',
    );
  });

  it('renders the two property groups IN ORDER, own → graph', () => {
    mount();
    const text = document.body.textContent ?? '';
    // The document's own bag, then everything this graph says about it.
    expect(text.indexOf('timezone')).toBeGreaterThan(-1);
    expect(text.indexOf('In Acme org chart')).toBeGreaterThan(text.indexOf('timezone'));
  });

  it('puts an AUTHORED field and a BOUND one in the same graph group', () => {
    mount();
    expect(screen.getByText('Stance')).toBeInTheDocument();
    expect(screen.getByText('skeptic')).toBeInTheDocument();
    // `title` carries a `from` binding, so it is never on `fields` — it shows read-only, in
    // the same group rather than under a caption naming the mechanism.
    expect(screen.getByText('cached CTO')).toBeInTheDocument();
    expect(screen.queryByText('Resolved elsewhere')).not.toBeInTheDocument();
  });

  it('prefers a HYDRATED bound value over the cache', () => {
    mount({ hydrated: { n1: { title: 'live CTO' } } });
    expect(screen.getByText('live CTO')).toBeInTheDocument();
    expect(screen.queryByText('cached CTO')).not.toBeInTheDocument();
  });

  it('draws a PROFILE binding in the header and never also as a row', () => {
    // `person.title` fills the header's title slot, so a `Title  cached CTO` row underneath it
    // would be the same fact twice — and the second copy is what made the panel read as a dump
    // of everything we happen to know rather than as a person.
    mount();
    expect(screen.getByText('cached CTO')).toBeInTheDocument();
    expect(screen.queryByText('Title')).not.toBeInTheDocument();
  });

  it('keeps the identity field out of the rail — `person` is plumbing, not a property', () => {
    // It holds an address or a `[[person: id]]` token: how the node knows WHO this is, not
    // something to tell a reader.
    mount();
    expect(screen.queryByText('Person')).not.toBeInTheDocument();
    expect(screen.queryByText('<email>')).not.toBeInTheDocument();
  });

  it('draws the face and the LinkedIn mark from their BINDINGS, not from field keys', () => {
    const { container } = mount();
    expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/marcus.jpg');
    expect(screen.getByLabelText('Open LinkedIn profile')).toHaveAttribute(
      'href',
      'https://linkedin.com/in/marcus',
    );
  });

  it('falls back to initials when nobody has enriched this person', () => {
    mount({ nodes: { ...NODES, n1: { ...MARCUS, derived: { title: 'cached CTO' } } } });
    expect(screen.getByText('ML')).toBeInTheDocument();
  });

  it('opens the real document through ↗, and only when it can', () => {
    const onOpenDocument = jest.fn();
    mount({ onOpenDocument });
    screen.getByLabelText('Open document').click();
    expect(onOpenDocument).toHaveBeenCalledWith('doc_marcus');

    // No handler, no button — a control that cannot do anything is worse than no control.
    mount();
    expect(screen.queryAllByLabelText('Open document')).toHaveLength(1);
  });

  it('closes', () => {
    const onClose = jest.fn();
    mount({ onClose });
    screen.getAllByLabelText('Close')[0].click();
    expect(onClose).toHaveBeenCalled();
  });
});

describe('a node whose document is gone', () => {
  it('says so, keeps the cached values, and does NOT fetch', () => {
    // There is no foreign key behind a node, so this message is the guard that replaces one —
    // and asking for a document we know is missing is three retries to learn what we knew.
    mount({ missingDocumentIds: new Set(['n1']) });
    expect(screen.getByText(/no longer resolves/i)).toBeInTheDocument();
    expect(screen.getByText('cached CTO')).toBeInTheDocument();
    expect(mockDocEnabled).toBe(false);
  });

  it('binds no editor — there is no document to bind', () => {
    mount({ missingDocumentIds: new Set(['n1']) });
    expect(screen.queryByTestId('document-body')).not.toBeInTheDocument();
  });

  it('falls back to the NODE title when there is no document to name it', () => {
    mount({ missingDocumentIds: new Set(['n1']) });
    expect(screen.getByRole('heading', { name: 'Marcus Lee' })).toBeInTheDocument();
  });
});

describe('a selected EDGE uses the same surface', () => {
  it('shows its label, its endpoints and its description', () => {
    mount({ selection: { kind: 'edge', edgeKey=[redacted] } });
    expect(screen.getByText('Boss/Manager')).toBeInTheDocument();
    expect(screen.getByText('Marcus Lee → Paula Rodgers')).toBeInTheDocument();
    expect(screen.getByText(/Solid line since the 2025 reorg/)).toBeInTheDocument();
  });

  it("types its fields by the KIND's declaration", () => {
    mount({ selection: { kind: 'edge', edgeKey=[redacted] } });
    expect(screen.getByText('Since')).toBeInTheDocument();
    expect(screen.getByText('2025-03')).toBeInTheDocument();
  });

  it('shows NO graph groups — an edge belongs to the graph, it is not a document in one', () => {
    mount({ selection: { kind: 'edge', edgeKey=[redacted] } });
    expect(screen.queryByText('In Acme org chart')).not.toBeInTheDocument();
    expect(screen.queryByText('Resolved elsewhere')).not.toBeInTheDocument();
  });
});

describe('a selection that no longer exists', () => {
  it('renders nothing rather than an empty card', () => {
    // A node removed while its card was open — the selection outlives the thing.
    const { container } = mount({ selection: { kind: 'node', nodeId: 'gone' } });
    expect(container).toBeEmptyDOMElement();
  });

  it('renders nothing for a stale edge key', () => {
    const { container } = mount({ selection: { kind: 'edge', edgeKey=[redacted] } });
    expect(container).toBeEmptyDOMElement();
  });
});