Skip to content

Commit 9d7ba2b

Browse files
fix(apollo-react): support ELK edge routing in SequenceEdge [MST-6513]
1 parent 63ccb73 commit 9d7ba2b

7 files changed

Lines changed: 768 additions & 32 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
arrowFromLastSegment,
5+
buildOrthogonalPath,
6+
calculatePathMidpoint,
7+
toOrthogonalPoints,
8+
} from './EdgeRoutingUtils';
9+
10+
describe('toOrthogonalPoints', () => {
11+
it('returns empty/single points unchanged', () => {
12+
expect(toOrthogonalPoints([])).toEqual([]);
13+
expect(toOrthogonalPoints([{ x: 1, y: 2 }])).toEqual([{ x: 1, y: 2 }]);
14+
});
15+
16+
it('passes through already-orthogonal points', () => {
17+
const pts = [
18+
{ x: 0, y: 0 },
19+
{ x: 100, y: 0 },
20+
{ x: 100, y: 100 },
21+
];
22+
const result = toOrthogonalPoints(pts);
23+
expect(result).toEqual(pts);
24+
});
25+
26+
it('snaps first bend to be axis-aligned with source', () => {
27+
const pts = [
28+
{ x: 0, y: 0 },
29+
{ x: 50, y: 2 }, // nearly horizontal — snap y to source.y
30+
{ x: 50, y: 100 },
31+
];
32+
const result = toOrthogonalPoints(pts);
33+
expect(result[1]!.y).toBe(0); // snapped to source y
34+
expect(result[1]!.x).toBe(50); // x unchanged
35+
});
36+
37+
it('snaps last bend to be axis-aligned with target', () => {
38+
const pts = [
39+
{ x: 0, y: 0 },
40+
{ x: 50, y: 0 },
41+
{ x: 50, y: 98 }, // nearly vertical to target at y=100
42+
{ x: 100, y: 100 },
43+
];
44+
const result = toOrthogonalPoints(pts);
45+
expect(result[result.length - 2]!.y).toBe(100); // snapped to target y
46+
});
47+
48+
it('inserts step midpoints for diagonal middle segments', () => {
49+
// Two points with both dx > 1 and dy > 1 after snapping
50+
const pts = [
51+
{ x: 0, y: 0 },
52+
{ x: 100, y: 100 },
53+
];
54+
const result = toOrthogonalPoints(pts);
55+
// Should insert a step: (0,0) → (50,0) → (50,100) → (100,100)
56+
expect(result.length).toBe(4);
57+
expect(result[1]).toEqual({ x: 50, y: 0 });
58+
expect(result[2]).toEqual({ x: 50, y: 100 });
59+
});
60+
61+
it('snaps sub-threshold differences to zero', () => {
62+
const pts = [
63+
{ x: 0, y: 0 },
64+
{ x: 100, y: 0.5 }, // dy < 1, within threshold — should snap to 0
65+
{ x: 100, y: 100 },
66+
];
67+
const result = toOrthogonalPoints(pts);
68+
expect(result[1]!.y).toBe(0); // snapped to source y
69+
expect(result[1]!.x).toBe(100); // x unchanged
70+
});
71+
});
72+
73+
describe('buildOrthogonalPath', () => {
74+
it('returns empty path for fewer than 2 points', () => {
75+
expect(buildOrthogonalPath([]).path).toBe('');
76+
expect(buildOrthogonalPath([{ x: 0, y: 0 }]).path).toBe('');
77+
});
78+
79+
it('returns a straight line for 2 points', () => {
80+
const { path } = buildOrthogonalPath([
81+
{ x: 0, y: 0 },
82+
{ x: 100, y: 0 },
83+
]);
84+
expect(path).toBe('M 0 0 L 100 0');
85+
});
86+
87+
it('returns orthoPoints alongside the path', () => {
88+
const { path, orthoPoints } = buildOrthogonalPath([
89+
{ x: 0, y: 0 },
90+
{ x: 100, y: 0 },
91+
{ x: 100, y: 100 },
92+
]);
93+
expect(path).toBeTruthy();
94+
expect(orthoPoints.length).toBeGreaterThanOrEqual(3);
95+
});
96+
97+
it('creates rounded corners for 3+ orthogonal points', () => {
98+
const { path } = buildOrthogonalPath(
99+
[
100+
{ x: 0, y: 0 },
101+
{ x: 100, y: 0 },
102+
{ x: 100, y: 100 },
103+
],
104+
16,
105+
);
106+
// Should contain M, L, Q, L commands
107+
expect(path).toMatch(/^M /);
108+
expect(path).toContain('Q');
109+
expect(path).toMatch(/L 100 100$/);
110+
});
111+
112+
it('clamps border radius to half the shorter segment', () => {
113+
// Short segment of 10px — radius should be clamped to 5
114+
const { path } = buildOrthogonalPath(
115+
[
116+
{ x: 0, y: 0 },
117+
{ x: 10, y: 0 },
118+
{ x: 10, y: 100 },
119+
],
120+
16,
121+
);
122+
// The Q control point should use r=5, not r=16
123+
// L before Q should be at x=5 (10 - 5)
124+
expect(path).toContain('L 5 0');
125+
});
126+
127+
it('falls back to L for very short segments (r < 1)', () => {
128+
const { path } = buildOrthogonalPath([
129+
{ x: 0, y: 0 },
130+
{ x: 0.5, y: 0 }, // segment length 0.5, r = 0.25 < 1
131+
{ x: 0.5, y: 100 },
132+
]);
133+
// Should contain L 0.5 0 (no Q curve)
134+
expect(path).not.toContain('Q');
135+
});
136+
137+
it('orthogonalizes non-axis-aligned input internally', () => {
138+
// Diagonal input — buildOrthogonalPath should snap it internally
139+
const { path, orthoPoints } = buildOrthogonalPath([
140+
{ x: 0, y: 0 },
141+
{ x: 100, y: 100 },
142+
]);
143+
// toOrthogonalPoints inserts step midpoints for diagonal segments
144+
expect(orthoPoints.length).toBe(4);
145+
expect(path).toBeTruthy();
146+
});
147+
});
148+
149+
describe('arrowFromLastSegment', () => {
150+
it('returns zero values for fewer than 2 points', () => {
151+
expect(arrowFromLastSegment([])).toEqual({ angle: 0, offsetX: 0, offsetY: 0 });
152+
expect(arrowFromLastSegment([{ x: 0, y: 0 }])).toEqual({ angle: 0, offsetX: 0, offsetY: 0 });
153+
});
154+
155+
it('detects rightward arrow (angle 0)', () => {
156+
const result = arrowFromLastSegment([
157+
{ x: 0, y: 0 },
158+
{ x: 100, y: 0 },
159+
]);
160+
expect(result.angle).toBe(0);
161+
expect(result.offsetX).toBeGreaterThan(0);
162+
expect(result.offsetY).toBe(0);
163+
});
164+
165+
it('detects leftward arrow (angle PI)', () => {
166+
const result = arrowFromLastSegment([
167+
{ x: 100, y: 0 },
168+
{ x: 0, y: 0 },
169+
]);
170+
expect(result.angle).toBe(Math.PI);
171+
expect(result.offsetX).toBeLessThan(0);
172+
expect(result.offsetY).toBe(0);
173+
});
174+
175+
it('detects downward arrow (angle PI/2)', () => {
176+
const result = arrowFromLastSegment([
177+
{ x: 0, y: 0 },
178+
{ x: 0, y: 100 },
179+
]);
180+
expect(result.angle).toBe(Math.PI / 2);
181+
expect(result.offsetX).toBe(0);
182+
expect(result.offsetY).toBeGreaterThan(0);
183+
});
184+
185+
it('detects upward arrow (angle -PI/2)', () => {
186+
const result = arrowFromLastSegment([
187+
{ x: 0, y: 100 },
188+
{ x: 0, y: 0 },
189+
]);
190+
expect(result.angle).toBe(-Math.PI / 2);
191+
expect(result.offsetX).toBe(0);
192+
expect(result.offsetY).toBeLessThan(0);
193+
});
194+
195+
it('uses the last two points only', () => {
196+
const result = arrowFromLastSegment([
197+
{ x: 0, y: 0 },
198+
{ x: 0, y: 50 }, // vertical
199+
{ x: 100, y: 50 }, // last segment is rightward
200+
]);
201+
expect(result.angle).toBe(0);
202+
});
203+
204+
it('skips trailing zero-length segments', () => {
205+
const result = arrowFromLastSegment([
206+
{ x: 0, y: 0 },
207+
{ x: 100, y: 0 }, // rightward
208+
{ x: 100, y: 0 }, // duplicate
209+
]);
210+
expect(result.angle).toBe(0);
211+
expect(result.offsetX).toBeGreaterThan(0);
212+
});
213+
214+
it('returns neutral fallback when all points are identical', () => {
215+
const result = arrowFromLastSegment([
216+
{ x: 50, y: 50 },
217+
{ x: 50, y: 50 },
218+
{ x: 50, y: 50 },
219+
]);
220+
expect(result).toEqual({ angle: 0, offsetX: 0, offsetY: 0 });
221+
});
222+
});
223+
224+
describe('calculatePathMidpoint', () => {
225+
it('returns origin for empty array', () => {
226+
expect(calculatePathMidpoint([])).toEqual({ x: 0, y: 0 });
227+
});
228+
229+
it('returns the point for single-element array', () => {
230+
expect(calculatePathMidpoint([{ x: 42, y: 99 }])).toEqual({ x: 42, y: 99 });
231+
});
232+
233+
it('returns midpoint for a straight 2-point segment', () => {
234+
const result = calculatePathMidpoint([
235+
{ x: 0, y: 0 },
236+
{ x: 100, y: 0 },
237+
]);
238+
expect(result.x).toBeCloseTo(50);
239+
expect(result.y).toBeCloseTo(0);
240+
});
241+
242+
it('finds midpoint along a multi-segment L-shaped path', () => {
243+
// Total length: 100 (horizontal) + 100 (vertical) = 200
244+
// Midpoint at length 100 = end of first segment = (100, 0)
245+
const result = calculatePathMidpoint([
246+
{ x: 0, y: 0 },
247+
{ x: 100, y: 0 },
248+
{ x: 100, y: 100 },
249+
]);
250+
expect(result.x).toBeCloseTo(100);
251+
expect(result.y).toBeCloseTo(0);
252+
});
253+
254+
it('handles midpoint falling in the middle of a segment', () => {
255+
// Total: 50 + 200 + 50 = 300, midpoint at 150 → 100 into the second segment (200 long)
256+
// Second segment: (50,0) → (50,200), t = 100/200 = 0.5, y = 100
257+
const result = calculatePathMidpoint([
258+
{ x: 0, y: 0 },
259+
{ x: 50, y: 0 },
260+
{ x: 50, y: 200 },
261+
{ x: 100, y: 200 },
262+
]);
263+
expect(result.x).toBeCloseTo(50);
264+
expect(result.y).toBeCloseTo(100);
265+
});
266+
267+
it('handles zero-length segments without errors', () => {
268+
const result = calculatePathMidpoint([
269+
{ x: 0, y: 0 },
270+
{ x: 0, y: 0 }, // zero-length
271+
{ x: 100, y: 0 },
272+
]);
273+
expect(result.x).toBeCloseTo(50);
274+
expect(result.y).toBeCloseTo(0);
275+
});
276+
});

0 commit comments

Comments
 (0)