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