tree-edge.test.ts4.7 KBView on GitHub
/**
 * The family-tree line.
 *
 * These are assertions about GEOMETRY, not about a path string, so each one reads the numbers
 * back out of the `d` attribute. The load-bearing property is the first describe: every child
 * of one parent turns on the SAME y. It is the thing four different card heights used to break,
 * and it is invisible in a unit test that only checks one edge at a time.
 */

import { getTreeEdgePath } from '@/modules/graph/tree-edge';

const OFFSET = 55;

/** Every `y` the path passes through, in order. */
function ysOf(path: string): number[] {
  return [...path.matchAll(/[ML] ?-?[\d.]+,(-?[\d.]+)/g)].map((m) => Number(m[1]));
}

/** The one horizontal run: the y both of its ends share. */
function busYOf(path: string): number {
  const ys = ysOf(path);
  // The `L …,busY` that precedes the second corner — index 2 of the four straight points.
  return ys[2] ?? Number.NaN;
}

describe('every child of one parent turns on the same line', () => {
  // A parent whose bottom edge is at y=200, and three children on the rank below whose cards
  // are 72, 150 and 210 tall — the spread that made smoothstep draw three separate runs.
  const parentBottom = 200;
  const childTop = 400;

  const edges = [
    getTreeEdgePath(
      { sourceX: 100, sourceY: childTop, targetX: 500, targetY: parentBottom },
      { offset: OFFSET },
    ),
    getTreeEdgePath(
      { sourceX: 500, sourceY: childTop, targetX: 500, targetY: parentBottom },
      { offset: OFFSET },
    ),
    getTreeEdgePath(
      { sourceX: 900, sourceY: childTop, targetX: 500, targetY: parentBottom },
      { offset: OFFSET },
    ),
  ];

  it('puts the bus a fixed distance below the PARENT, not midway between each pair', () => {
    const [left, , right] = edges;
    expect(busYOf(left![0])).toBe(parentBottom + OFFSET);
    expect(busYOf(right![0])).toBe(parentBottom + OFFSET);
  });

  it('agrees across siblings — which is the whole point', () => {
    const [left, , right] = edges;
    expect(busYOf(left![0])).toBe(busYOf(right![0]));
  });

  it('hangs the label on the bus, centred on the run', () => {
    const [, labelX, labelY] = edges[0]!;
    expect(labelX).toBe(300);
    expect(labelY).toBe(parentBottom + OFFSET);
  });
});

describe('a child directly beneath its parent', () => {
  it('draws ONE straight segment — no jog out and back', () => {
    const [path] = getTreeEdgePath(
      { sourceX: 500, sourceY: 400, targetX: 500, targetY: 200 },
      { offset: OFFSET },
    );
    expect(path).toBe('M 500,400 L 500,200');
  });

  it('tolerates the sub-pixel drift of two measured DOM boxes', () => {
    // Card centres come off `measured` widths and land on fractions. Half a pixel of
    // disagreement is not a reason to draw two corners.
    const [path] = getTreeEdgePath(
      { sourceX: 500.4, sourceY: 400, targetX: 500, targetY: 200 },
      { offset: OFFSET },
    );
    expect(path).not.toContain('Q');
  });
});

describe('direction', () => {
  it('routes the same way when the SOURCE is the upper card', () => {
    // A dragged node can put either end on top; `getFloatingEdgeParams` flips the anchors and
    // the bus still belongs to whichever card is above.
    const [path] = getTreeEdgePath(
      { sourceX: 500, sourceY: 200, targetX: 900, targetY: 400 },
      { offset: OFFSET },
    );
    expect(busYOf(path)).toBe(200 + OFFSET);
  });

  it('starts at the source anchor and ends at the target anchor, either way round', () => {
    const [path] = getTreeEdgePath(
      { sourceX: 100, sourceY: 400, targetX: 500, targetY: 200 },
      { offset: OFFSET },
    );
    expect(path.startsWith('M 100,400')).toBe(true);
    expect(path.endsWith('L 500,200')).toBe(true);
  });
});

describe('cramped pairs', () => {
  it('never routes the bus past the lower card', () => {
    // Two cards dragged almost on top of each other: a 55px offset would put the horizontal run
    // below the child it is connecting to, drawing a line through the card.
    const [path] = getTreeEdgePath(
      { sourceX: 100, sourceY: 210, targetX: 500, targetY: 200 },
      { offset: OFFSET },
    );
    expect(busYOf(path)).toBe(210);
  });

  it('shrinks its corners rather than overshooting a short run', () => {
    // A 4px horizontal run cannot hold two 8px corners. Every x in the path must stay inside
    // the two endpoints, or the line visibly bulges past the cards it joins.
    const [path] = getTreeEdgePath(
      { sourceX: 100, sourceY: 400, targetX: 104, targetY: 200 },
      { offset: OFFSET },
    );
    const xs = [...path.matchAll(/(-?[\d.]+),-?[\d.]+/g)].map((m) => Number(m[1]));
    expect(Math.min(...xs)).toBeGreaterThanOrEqual(100);
    expect(Math.max(...xs)).toBeLessThanOrEqual(104);
  });
});