208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
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 })
|
|
})
|
|
})
|