import { describe, it, expect } from 'vitest' import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils' describe('buildWaypointPath — bezier (default)', () => { it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { // With only 2 pts (src + target), catmull-rom = cubic bezier const path = buildWaypointPath(0, 0, [], 100, 100) expect(path).toMatch(/^M 0 0 C/) }) it('routes through a single waypoint with smooth curve', () => { const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100) expect(path).toMatch(/^M 0 0 C/) // Should not be a straight polyline expect(path).not.toContain(' L ') }) it('routes through multiple waypoints', () => { const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100) expect(path).toMatch(/^M 0 0 C/) }) }) describe('buildWaypointPath — smooth style', () => { it('builds a direct straight line with no waypoints (no bend)', () => { // Only 2 points → no intermediate vertex → no rounding needed expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100') }) it('routes through a single waypoint with straight lines (no intermediate bend)', () => { // 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth') // Should start at source and end at target expect(path).toMatch(/^M 0 0/) expect(path).toMatch(/100 100$/) // Should contain a quadratic bezier at the waypoint corner expect(path).toContain('Q') }) it('routes through multiple waypoints with rounded corners', () => { const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') expect(path).toMatch(/^M 0 0/) expect(path).toMatch(/100 100$/) expect(path).toContain('Q') }) it('does not round corners when segment is too short (r clamped to 0)', () => { // Adjacent waypoints very close together — r → 0, falls back to L const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth') expect(path).toMatch(/^M 0 0/) }) }) describe('snap45', () => { // Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px) it('snaps horizontal direction when close (deviation < threshold)', () => { // (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 }) expect(r.y).toBe(0) expect(r.x).toBeGreaterThan(0) }) it('snaps vertical direction when close', () => { const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 }) expect(r.x).toBe(0) expect(r.y).toBeGreaterThan(0) }) it('snaps 45° diagonal when close', () => { // (80, 83) — nearly 45°, deviation ≈ 2px → snaps const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 }) expect(r.x).toBe(r.y) }) it('does NOT snap when deviation exceeds threshold', () => { // (100, 40) — deviation from 0° is ~40px > 15 → no snap const pos = { x: 100, y: 40 } const r = snap45({ x: 0, y: 0 }, pos) expect(r).toEqual(pos) }) it('returns pos unchanged when distance < 1', () => { const pos = { x: 5, y: 5 } expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos) }) it('preserves distance from origin when snapping', () => { const from = { x: 0, y: 0 } const pos = { x: 100, y: 3 } // close to horizontal const r = snap45(from, pos) const origDist = Math.hypot(pos.x - from.x, pos.y - from.y) const snapDist = Math.hypot(r.x - from.x, r.y - from.y) expect(snapDist).toBeCloseTo(origDist, 0) }) }) describe('snap45both', () => { it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => { // prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 }) // Result must be on a 45°-ray from (0,0) const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) // Result must be on a 45°-ray from (100,100) const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4) expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) }) it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => { // prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each) // pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100) const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 }) const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4) expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05) const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4) expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05) }) it('returns raw pos when beyond threshold', () => { // pos=(100,80) is 20px from nearest intersection (100,100) → no snap const pos = { x: 100, y: 80 } const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos) expect(r).toEqual(pos) }) it('falls back gracefully when prev === next', () => { // No valid intersection → fallback to snap45 const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 }) expect(r).toBeDefined() }) }) describe('distToSegment', () => { it('returns 0 when point is on the segment', () => { expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0) }) it('returns perpendicular distance when point is beside segment', () => { expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10) }) it('returns distance to nearest endpoint when point is past the segment', () => { expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100) }) it('handles zero-length segment (a === b)', () => { expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5) }) }) describe('findInsertIndex', () => { it('returns 0 when there are no waypoints (only one segment)', () => { expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0) }) it('inserts before first waypoint when click is on first segment', () => { const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 }) expect(idx).toBe(0) }) it('inserts after first waypoint when click is on second segment', () => { const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 }) expect(idx).toBe(1) }) it('picks the closest segment among multiple', () => { const idx = findInsertIndex( 0, 0, [{ x: 100, y: 0 }, { x: 100, y: 100 }], 200, 100, { x: 150, y: 105 }, ) expect(idx).toBe(2) }) }) describe('getWaypointLabelPosition', () => { it('uses the routed midpoint for a symmetric bezier waypoint path', () => { const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0) expect(point.x).toBeCloseTo(50, 0) expect(point.y).toBeCloseTo(100, 0) }) it('uses the routed midpoint for a smooth waypoint path', () => { const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth') expect(point.x).toBeCloseTo(50, 0) expect(point.y).toBeCloseTo(50, 0) }) it('falls back to the source point when the path is degenerate', () => { const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth') expect(point).toEqual({ x: 10, y: 20 }) }) }) describe('getAddWaypointHandlePosition', () => { it('places bezier add handle on the rendered curved segment', () => { const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier') expect(point.x).toBeCloseTo(21.875, 3) expect(point.y).toBeCloseTo(56.25, 3) }) it('keeps smooth add handle at straight segment midpoint', () => { const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth') expect(point).toEqual({ x: 50, y: 50 }) }) })