floating-edge.test.ts5.1 KBView on GitHub
/**
 * Which side of each card an edge leaves from and arrives at.
 *
 * The bug: handles were fixed at source-bottom → target-top, which is right only while the
 * source sits above the target. Drag Peter above the AEs pointing at him and every edge had to
 * leave its AE's bottom, turn around, and travel back up past the whole card.
 */

import { Position } from '@xyflow/react';

import { getFloatingEdgeParams, type NodeBox } from '@/modules/graph/floating-edge';

/** A node box at (x, y), 200×72 — the card's own dimensions. */
function nodeAt(x: number, y: number, height = 72): NodeBox {
  return {
    internals: { positionAbsolute: { x, y } },
    measured: { width: 200, height },
  };
}

describe('the sides flip with the cards', () => {
  it('source ABOVE target leaves the bottom and enters the top', () => {
    const p = getFloatingEdgeParams(nodeAt(0, 0), nodeAt(0, 300));
    expect(p.sourcePosition).toBe(Position.Bottom);
    expect(p.targetPosition).toBe(Position.Top);
    expect(p.sy).toBe(72); // the source's bottom edge
    expect(p.ty).toBe(300); // the target's top edge
  });

  it('source BELOW target leaves the top and enters the bottom', () => {
    // This is the dragged case. Before the fix both endpoints kept their original sides and the
    // line looped around two whole cards to get between them.
    const p = getFloatingEdgeParams(nodeAt(0, 300), nodeAt(0, 0));
    expect(p.sourcePosition).toBe(Position.Top);
    expect(p.targetPosition).toBe(Position.Bottom);
    expect(p.sy).toBe(300);
    expect(p.ty).toBe(72);
  });

  it('always anchors at the horizontal CENTRE, so the line meets the card square', () => {
    const p = getFloatingEdgeParams(nodeAt(0, 0), nodeAt(500, 300));
    expect(p.sx).toBe(100);
    expect(p.tx).toBe(600);
  });

  it('takes the SHORTER vertical route in both directions', () => {
    const above = getFloatingEdgeParams(nodeAt(0, 0), nodeAt(0, 300));
    const below = getFloatingEdgeParams(nodeAt(0, 300), nodeAt(0, 0));
    // The gap the line actually spans is the same small distance either way round — 228px of
    // clear space between the cards — rather than looping the height of a card on one of them.
    expect(Math.abs(above.ty - above.sy)).toBe(228);
    expect(Math.abs(below.ty - below.sy)).toBe(228);
  });
});

describe('cards of different heights', () => {
  it('compares RANK LINES, so a tall card does not flip a clearly-ordered pair', () => {
    // A node with three displayed fields is taller. Comparing nearest EDGES would say the tall
    // upper card is "below" because its bottom reaches past the short one's top; comparing
    // CENTRES is what used to separate siblings. The top edge is neither — it is the rank the
    // layout put the card on, which is the thing "above" actually means here.
    const tallUpper = nodeAt(0, 0, 200);
    const shortLower = nodeAt(0, 150, 40);
    const p = getFloatingEdgeParams(tallUpper, shortLower);
    expect(p.sourcePosition).toBe(Position.Bottom);
    expect(p.targetPosition).toBe(Position.Top);
  });
});

describe('degenerate positions', () => {
  it('resolves an exact tie the same way every time, rather than flickering', () => {
    const a = getFloatingEdgeParams(nodeAt(0, 100), nodeAt(400, 100));
    const b = getFloatingEdgeParams(nodeAt(0, 100), nodeAt(400, 100));
    expect(a).toEqual(b);
    expect(a.sourcePosition).toBe(Position.Bottom);
  });

  it('survives an unmeasured node with the card\'s own dimensions', () => {
    // `measured` is undefined for one frame after mount. The fallback keeps the anchor on the
    // card rather than at its top-left corner.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- React Flow's internal shape
    const unmeasured = { internals: { positionAbsolute: { x: 0, y: 0 } } } as any;
    const p = getFloatingEdgeParams(unmeasured, nodeAt(0, 300));
    expect(p.sx).toBe(100);
    expect(p.sy).toBe(72);
  });
});

describe('siblings on one rank never disagree', () => {
  it('anchors two children of different HEIGHTS the same way', () => {
    // The regression this exists for: anchors were picked by comparing card CENTRES, and two
    // people on the same row of a chart have different centres whenever their cards differ in
    // height (one displays three fields, the other none). When the boss's centre fell between
    // the two, one child connected upward and the other downward — so one line looped around a
    // card for no reason a reader could see. A rank is a shared TOP edge, so tops are compared.
    const boss = nodeAt(300, 0, 96);
    const shortChild = nodeAt(100, 300, 72);
    const tallChild = nodeAt(500, 300, 180);

    const a = getFloatingEdgeParams(shortChild, boss);
    const b = getFloatingEdgeParams(tallChild, boss);

    expect(a.sourcePosition).toBe(b.sourcePosition);
    expect(a.targetPosition).toBe(b.targetPosition);
    // Both leave the child's TOP and enter the boss's BOTTOM — the child is the edge's source.
    expect(a.sourcePosition).toBe(Position.Top);
    expect(a.targetPosition).toBe(Position.Bottom);
    // And they enter the boss on the same y, so the two lines share one horizontal run.
    expect(a.ty).toBe(b.ty);
  });
});