tierLayout.test.ts5.0 KBView on GitHub
/**
 * The fallback shape for a graph whose edges do not give it one.
 *
 * Every assertion here traces to one measured failure: a real 37-person chart with no reporting
 * lines laid out as a single row 9,380px wide, which `fitView` rendered at ~8% zoom — two
 * adjacent people read as one node. These tests are what stop that coming back.
 */

import { CARD_HEIGHT, CARD_WIDTH } from '@/modules/graph/layout-metrics';
import { tierLayout, tierOf } from '@/modules/graph/tierLayout';
import type { GraphNode, GraphNodeField } from '@zero/server/graph';

const SENIORITY: GraphNodeField = {
  key=[redacted],
  label: 'Seniority',
  type: 'select',
  options: ['c_level', 'vp', 'director', 'manager', 'ic'],
};

function node(id: string, seniority?: string, extra: Partial<GraphNode> = {}): GraphNode {
  return {
    id,
    documentId: `doc_${id}`,
    title: id,
    fields: seniority ? { seniority } : {},
    ...extra,
  };
}

const labelOf = (id: string) => id;

describe('tierOf', () => {
  it("uses the field's own OPTION ORDER, so the schema author sets the vertical order", () => {
    expect(tierOf(node('a', 'c_level'), SENIORITY)).toBe(0);
    expect(tierOf(node('a', 'vp'), SENIORITY)).toBe(1);
    expect(tierOf(node('a', 'ic'), SENIORITY)).toBe(4);
  });

  it('puts an UNRECORDED value BELOW every declared tier, never at the top', () => {
    // "We have not recorded this person's seniority" must not read as "this person is the CEO".
    expect(tierOf(node('a'), SENIORITY)).toBe(5);
    expect(tierOf(node('a', 'founder'), SENIORITY)).toBe(5);
    expect(tierOf(node('a', ''), SENIORITY)).toBe(5);
  });

  it('reads a BOUND field from the derived cache, like the node face does', () => {
    const bound: GraphNodeField = { ...SENIORITY, from: 'person.seniority' };
    expect(tierOf({ fields: {}, derived: { seniority: 'vp' } }, bound)).toBe(1);
  });

  it('takes the first value of a multi_select', () => {
    expect(tierOf({ fields: { seniority: ['vp', 'ic'] } }, SENIORITY)).toBe(1);
  });

  it('puts everything in one tier when no field is named', () => {
    expect(tierOf(node('a', 'vp'), undefined)).toBe(0);
  });
});

describe('tierLayout', () => {
  it('STACKS nodes instead of laying them in one row — the 9,380px bug', () => {
    const nodes = Array.from({ length: 37 }, (_, i) =>
      node(`n${i}`, ['c_level', 'vp', 'director', 'manager', 'ic'][i % 5]),
    );
    const pos = tierLayout({ nodes, tierField: SENIORITY, labelOf });

    const ys = new Set(Object.values(pos).map((p) => p.y));
    expect(ys.size).toBe(5);

    const width =
      Math.max(...Object.values(pos).map((p) => p.x)) -
      Math.min(...Object.values(pos).map((p) => p.x));
    // The old behaviour was 9,380px across. Eight per row is an order of magnitude narrower.
    expect(width).toBeLessThan(3000);
  });

  it('puts the most senior tier at the TOP', () => {
    const pos = tierLayout({
      nodes: [node('ceo', 'c_level'), node('eng', 'ic'), node('vp', 'vp')],
      tierField: SENIORITY,
      labelOf,
    });
    expect(pos.ceo.y).toBeLessThan(pos.vp.y);
    expect(pos.vp.y).toBeLessThan(pos.eng.y);
  });

  it('CENTRES each row against the widest, so it reads as a tree not a table', () => {
    const pos = tierLayout({
      nodes: [node('boss', 'c_level'), node('a', 'ic'), node('b', 'ic'), node('c', 'ic')],
      tierField: SENIORITY,
      labelOf,
    });
    const bottom = ['a', 'b', 'c'].map((id) => pos[id].x);
    const bottomCentre = (Math.min(...bottom) + Math.max(...bottom)) / 2;
    expect(pos.boss.x).toBeCloseTo(bottomCentre, 0);
  });

  it('never overlaps two cards', () => {
    const nodes = Array.from({ length: 24 }, (_, i) => node(`n${i}`, i < 3 ? 'vp' : 'ic'));
    const pos = tierLayout({ nodes, tierField: SENIORITY, labelOf });
    const pts = Object.values(pos);
    for (let i = 0; i < pts.length; i += 1) {
      for (let j = i + 1; j < pts.length; j += 1) {
        // Measured against the card's REAL height, not its `minHeight`. Checking 72px was the
        // assumption that let the rows come out nearly touching in the first place.
        const clash =
          Math.abs(pts[i].x - pts[j].x) < CARD_WIDTH && Math.abs(pts[i].y - pts[j].y) < CARD_HEIGHT;
        expect(clash).toBe(false);
      }
    }
  });

  it('is deterministic — two calls agree, so a re-render does not reshuffle', () => {
    const nodes = [node('zoe', 'vp'), node('adam', 'vp'), node('mia', 'ic')];
    expect(tierLayout({ nodes, tierField: SENIORITY, labelOf })).toEqual(
      tierLayout({ nodes, tierField: SENIORITY, labelOf }),
    );
  });

  it('orders within a tier by LABEL, not by insertion', () => {
    const pos = tierLayout({
      nodes: [node('zoe', 'vp'), node('adam', 'vp')],
      tierField: SENIORITY,
      labelOf,
    });
    expect(pos.adam.x).toBeLessThan(pos.zoe.x);
  });

  it('still lays out a graph whose field is absent — one row, honestly', () => {
    const pos = tierLayout({ nodes: [node('a'), node('b')], labelOf });
    expect(pos.a.y).toBe(pos.b.y);
    expect(Object.keys(pos)).toHaveLength(2);
  });
});