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