graph-to-flow.test.ts13.7 KBView on GitHub /**
* The domain ⇄ React Flow adapter.
*
* The assertions are mostly about what the adapter DOESN'T do: which edges reach the layout,
* what it refuses to write back, and which values win when three sources disagree.
*/
import type { GraphEdge, GraphNode, GraphSchema } from '@zero/server/graph';
import { connectionToEdge, dragToMove } from '@/modules/graph/flow-to-graph';
import { graphToFlow, nodeColorOf, resolveNodeValue } from '@/modules/graph/graph-to-flow';
import type { GraphNodeData } from '@/modules/graph/graph-flow-types';
function node(id: string, title: string, fields: Record<string, unknown> = {}): GraphNode {
return { id, documentId: `doc_${id}`, title, fields };
}
function edge(source: string, target: string, kind: string): GraphEdge {
return { id: `${source}:${target}:${kind}`, source, target, kind };
}
function index<T extends { id: string }>(items: T[]): Record<string, T> {
return Object.fromEntries(items.map((i) => [i.id, i]));
}
const SCHEMA: GraphSchema = {
version: 1,
visibility: 'team',
nodeSchema: {
fields: [
{ key=[redacted], label: 'Title', type: 'text', from: 'person.title', displayOnCard: true },
{ key=[redacted], label: 'Stance', type: 'select', displayOnCard: true },
],
},
edgeKinds: [
{ key=[redacted], label: 'Reports to' },
{ key=[redacted], label: 'Worked with', directed: false },
],
view: { layoutEdgeKind: 'reports_to' },
};
/** Paula ← Marcus ← Sam, plus a non-layout worked_with between Sam and Paula. */
function acme() {
return {
schema: SCHEMA,
nodes: index([node('n1', 'Paula'), node('n2', 'Marcus'), node('n3', 'Sam')]),
edges: index([
edge('n2', 'n1', 'reports_to'),
edge('n3', 'n2', 'reports_to'),
edge('n3', 'n1', 'worked_with'),
]),
layout: {},
};
}
describe('graphToFlow', () => {
it('gives EVERY node a position, including one with no edges', () => {
const flow = graphToFlow({ ...acme(), nodes: { ...acme().nodes, n9: node('n9', 'Lonely') } });
expect(flow.nodes).toHaveLength(4);
for (const n of flow.nodes) {
expect(Number.isFinite(n.position.x)).toBe(true);
expect(Number.isFinite(n.position.y)).toBe(true);
}
});
it("uses the node's STORED position when it has one, and reports only the computed ones", () => {
const flow = graphToFlow({ ...acme(), layout: { n1: { x: 42, y: 7 } } });
expect(flow.nodes.find((n) => n.id === 'n1')?.position).toEqual({ x: 42, y: 7 });
// `toPersist` is what the caller writes ONCE, so the layout stops being recomputed on every
// open. A node that already had a position is never in it.
expect(Object.keys(flow.toPersist).sort()).toEqual(['n2', 'n3']);
});
it('is empty in `toPersist` once every node has a position', () => {
const flow = graphToFlow({
...acme(),
layout: { n1: { x: 0, y: 0 }, n2: { x: 0, y: 1 }, n3: { x: 0, y: 2 } },
});
expect(flow.toPersist).toEqual({});
});
it('lays out over ONLY the layout edge kind', () => {
// `worked_with` is not the axis, so it contributes nothing to the ranking.
const withOnlyLayoutEdges = graphToFlow({
...acme(),
edges: index([edge('n2', 'n1', 'reports_to'), edge('n3', 'n2', 'reports_to')]),
});
const withExtra = graphToFlow(acme());
for (const id of ['n1', 'n2', 'n3']) {
expect(withExtra.nodes.find((n) => n.id === id)?.position).toEqual(
withOnlyLayoutEdges.nodes.find((n) => n.id === id)?.position,
);
}
});
it('re-ranks when the view names a DIFFERENT axis, and writes nothing', () => {
const byReporting = graphToFlow(acme());
const byWorkedWith = graphToFlow({
...acme(),
schema: { ...SCHEMA, view: { layoutEdgeKind: 'worked_with' } },
});
// Pointing the axis at a different relation genuinely re-ranks the graph. Asserted over the
// WHOLE layout rather than one node: under both axes some individual node can land at the
// same rank by coincidence, and a per-node assertion would be flaky for a reason that has
// nothing to do with the behaviour.
const layoutOf = (flow: typeof byReporting) =>
Object.fromEntries(flow.nodes.map((n) => [n.id, n.position]));
expect(layoutOf(byReporting)).not.toEqual(layoutOf(byWorkedWith));
// And neither pass mutated the inputs it was handed — re-ranking WRITES NOTHING.
expect(acme().layout).toEqual({});
});
it('adding a NON-LAYOUT edge moves nobody', () => {
const before = graphToFlow({
...acme(),
edges: index([edge('n2', 'n1', 'reports_to'), edge('n3', 'n2', 'reports_to')]),
});
const after = graphToFlow(acme());
expect(after.nodes.map((n) => n.position)).toEqual(before.nodes.map((n) => n.position));
});
it('uses `dagre-lr` when the view asks for it', () => {
const tb = graphToFlow(acme());
const lr = graphToFlow({
...acme(),
schema: { ...SCHEMA, view: { layoutEdgeKind: 'reports_to', mode: 'dagre-lr' } },
});
const spreadX = (flow: typeof tb) =>
Math.max(...flow.nodes.map((n) => n.position.x)) -
Math.min(...flow.nodes.map((n) => n.position.x));
expect(spreadX(lr)).toBeGreaterThan(spreadX(tb));
});
it('holds the two invariants: node.id is the graphNodes key, node.type is always "node"', () => {
// One node schema per graph, therefore one node component — never one per domain.
for (const n of graphToFlow(acme()).nodes) {
expect(n.type).toBe('node');
expect(n.data.node.id).toBe(n.id);
}
});
it('DROPS an edge naming a node that is not on the graph', () => {
// It would otherwise render as a line to nowhere. `lint` reports it; the canvas does not
// draw it.
const flow = graphToFlow({
...acme(),
edges: index([edge('n2', 'n1', 'reports_to'), edge('n1', 'ghost', 'reports_to')]),
});
expect(flow.edges.map((e) => e.id)).toEqual(['n2:n1:reports_to']);
});
it('hides an edge kind the view hides, and hides no NODE', () => {
const flow = graphToFlow({
...acme(),
schema: { ...SCHEMA, view: { layoutEdgeKind: 'reports_to', hiddenEdgeKinds: ['worked_with'] } },
});
expect(flow.edges.every((e) => e.data?.kind !== 'worked_with')).toBe(true);
// A node that renders nowhere has silently disappeared, which is the one outcome a graph may
// not produce.
expect(flow.nodes).toHaveLength(3);
});
it('carries the declared KIND onto the edge, and leaves an undeclared one plain', () => {
const flow = graphToFlow({
...acme(),
edges: index([edge('n2', 'n1', 'reports_to'), edge('n3', 'n1', 'golfs_with')]),
});
expect(flow.edges.find((e) => e.data?.kind === 'reports_to')?.data?.edgeKind?.label).toBe(
'Reports to',
);
const undeclared = flow.edges.find((e) => e.data?.kind === 'golfs_with');
expect(undeclared?.data?.edgeKind).toBeUndefined();
// Still drawn, and labelled with its own key so a typo is discoverable.
expect(undeclared?.label).toBe('golfs_with');
});
it("falls back to the DOCUMENT id for a node with no title, never the node id", () => {
// A node id names nothing outside this one graph.
const flow = graphToFlow({ ...acme(), nodes: index([{ ...node('n1', ''), title: undefined }]) });
expect(flow.nodes[0].data.title).toBe('doc_n1');
});
it('marks a node whose document is missing', () => {
const flow = graphToFlow({ ...acme(), missingDocumentIds: new Set(['n2']) });
expect(flow.nodes.find((n) => n.id === 'n2')?.data.missingDocument).toBe(true);
expect(flow.nodes.find((n) => n.id === 'n1')?.data.missingDocument).toBeUndefined();
});
});
describe('resolveNodeValue — hydrated ?? derived ?? fields, in that order', () => {
const data = (over: Partial<GraphNodeData>): GraphNodeData => ({
node: { ...node('n1', 'Marcus', { stance: 'skeptic' }), derived: { title: 'cached CTO' } },
title: 'Marcus',
nodeSchema: SCHEMA.nodeSchema,
...over,
});
it('shows an AUTHORED value as authored', () => {
expect(resolveNodeValue(data({}), 'stance')).toBe('skeptic');
});
it('shows the CACHE while hydration has not arrived', () => {
expect(resolveNodeValue(data({}), 'title')).toBe('cached CTO');
});
it('lets HYDRATION override the cache once it lands', () => {
// The three can disagree in exactly one direction — toward the document — never the other.
expect(resolveNodeValue(data({ hydrated: { title: 'live CTO' } }), 'title')).toBe('live CTO');
});
it('returns undefined for a key nothing carries', () => {
expect(resolveNodeValue(data({}), 'nothing')).toBeUndefined();
});
});
describe('flow-to-graph — only TWO gestures write', () => {
it('a drag becomes one moveNode call', () => {
expect(dragToMove({ id: 'n1', position: { x: 12.5, y: -3 } })).toEqual({
node: 'n1',
position: { x: 12.5, y: -3 },
});
});
it('a drawn connection becomes a connect with the selected kind', () => {
expect(
connectionToEdge({ source: 'a', target: 'b', sourceHandle: null, targetHandle: null }, 'reports_to'),
).toEqual({ source: 'a', target: 'b', kind: 'reports_to' });
});
it('refuses a connection a graph cannot hold, rather than showing it then failing', () => {
const base = { sourceHandle: null, targetHandle: null };
expect(connectionToEdge({ ...base, source: 'a', target: 'a' }, 'k')).toBeNull();
expect(connectionToEdge({ ...base, source: '', target: 'b' }, 'k')).toBeNull();
expect(connectionToEdge({ ...base, source: 'a', target: 'b' }, '')).toBeNull();
});
});
describe('the layout FALLS BACK to tiers when no edge can rank the graph', () => {
// The measured failure: a 37-person chart with no reporting lines laid out as one row
// 9,380px wide, unreadable at the zoom `fitView` picks.
const tiered: GraphSchema = {
...SCHEMA,
nodeSchema: {
fields: [
...SCHEMA.nodeSchema.fields,
{ key=[redacted], label: 'Seniority', type: 'select', options: ['c_level', 'vp', 'ic'] },
],
},
view: { layoutEdgeKind: 'reports_to', tierField: 'seniority' },
};
function people() {
return index([
{ ...node('n1', 'Paula'), fields: { seniority: 'c_level' } },
{ ...node('n2', 'Marcus'), fields: { seniority: 'vp' } },
{ ...node('n3', 'Sam'), fields: { seniority: 'ic' } },
{ ...node('n4', 'Jules'), fields: { seniority: 'ic' } },
]);
}
it('stacks them by the tier field when there are NO layout edges', () => {
const flow = graphToFlow({ schema: tiered, nodes: people(), edges: {}, layout: {} });
const y = (id: string) => flow.nodes.find((n) => n.id === id)!.position.y;
expect(y('n1')).toBeLessThan(y('n2'));
expect(y('n2')).toBeLessThan(y('n3'));
expect(y('n3')).toBe(y('n4'));
});
it('PREFERS real edges — a tier is the fallback, never an override', () => {
const flow = graphToFlow({
schema: tiered,
nodes: people(),
// Sam (ic) reports to Paula (c_level): the EDGE puts Paula above Sam, and it wins over
// the seniority tiers — which would have placed them three rows apart instead of one.
edges: index([edge('n3', 'n1', 'reports_to')]),
layout: {},
});
const y = (id: string) => flow.nodes.find((n) => n.id === id)!.position.y;
// Paula is the boss, so Paula is on top. This assertion used to read the other way round,
// which is how the chart shipped upside down.
expect(y('n1')).toBeLessThan(y('n3'));
});
it('lays out in one row when nothing says otherwise — honestly, not by accident', () => {
const flow = graphToFlow({
schema: { ...SCHEMA, view: {} },
nodes: people(),
edges: {},
layout: {},
});
expect(new Set(flow.nodes.map((n) => n.position.y)).size).toBe(1);
});
});
describe('nodeColorOf — the tint comes from the SCHEMA, never from the renderer', () => {
const colored: GraphSchema = {
...SCHEMA,
nodeSchema: {
fields: [
{
key=[redacted],
label: 'Stance',
type: 'select',
options: ['supporter', 'skeptic'],
optionColors: { supporter: 'green', skeptic: 'amber' },
},
{ key=[redacted], label: 'Role', type: 'multi_select', optionColors: { champion: 'blue' } },
{ key=[redacted], label: 'Title', type: 'text', from: 'person.title', optionColors: { CTO: 'violet' } },
],
},
view: { colorField: 'stance' },
};
it('paints from the field `view.colorField` names', () => {
expect(nodeColorOf(colored, { fields: { stance: 'supporter' } })).toBe('green');
expect(nodeColorOf(colored, { fields: { stance: 'skeptic' } })).toBe('amber');
});
it('paints nothing when the value has no declared colour', () => {
// An unrecognised name must render as NO tint — a wrong-but-present colour reads as a fact.
expect(nodeColorOf(colored, { fields: { stance: 'opponent' } })).toBeUndefined();
});
it('paints nothing when the node has no value, and when no field is chosen', () => {
expect(nodeColorOf(colored, { fields: {} })).toBeUndefined();
expect(nodeColorOf({ ...colored, view: {} }, { fields: { stance: 'supporter' } })).toBeUndefined();
});
it('takes the FIRST value of a multi_select — one node, one background', () => {
const byRole = { ...colored, view: { colorField: 'role' } };
expect(nodeColorOf(byRole, { fields: { role: ['champion', 'end_user'] } })).toBe('blue');
});
it('reads a BOUND colour field from the derived cache', () => {
const byTitle = { ...colored, view: { colorField: 'title' } };
expect(nodeColorOf(byTitle, { fields: {}, derived: { title: 'CTO' } })).toBe('violet');
});
it('threads the colour onto the node data the card reads', () => {
const flow = graphToFlow({
schema: colored,
nodes: index([{ ...node('n1', 'Paula'), fields: { stance: 'supporter' } }]),
edges: {},
layout: {},
});
expect(flow.nodes[0].data.color).toBe('green');
});
});