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