diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 2d94b64..7467751 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -35,7 +35,7 @@ import CustomEdge from "../Edges/CustomEdge"; import ContextMenu from "./ContextMenu"; import EmptyState from "../Common/EmptyState"; import { createNode } from "../../utils/nodeUtils"; -import { groupParallelEdges, calculateEdgeOffsetMultiplier } from "../../utils/edgeUtils"; +import { groupParallelEdges, calculateEdgeOffsetMultiplier, generateEdgeId } from "../../utils/edgeUtils"; import DeleteIcon from "@mui/icons-material/Delete"; import GroupWorkIcon from "@mui/icons-material/GroupWork"; import UngroupIcon from "@mui/icons-material/CallSplit"; @@ -751,8 +751,8 @@ const GraphEditor = ({ presentationMode = false, onNodeSelect, onEdgeSelect, onG const defaultDirectionality = edgeTypeConfig?.defaultDirectionality || 'directed'; // Generate a unique edge ID that allows multiple edges between same nodes - // Include timestamp to ensure uniqueness - const edgeId = `edge_${connection.source}_${connection.target}_${Date.now()}`; + // Use UUID to guarantee uniqueness without collision risk + const edgeId = generateEdgeId(connection.source, connection.target); // Create edge with custom data and unique ID (don't use addEdge to allow duplicates) const newEdge: Relation = { diff --git a/src/stores/graphStore.test.ts b/src/stores/graphStore.test.ts index 291dfb9..19794d3 100644 --- a/src/stores/graphStore.test.ts +++ b/src/stores/graphStore.test.ts @@ -324,16 +324,18 @@ describe('graphStore', () => { expect(state.edges).toHaveLength(2); }); - it('should use React Flow addEdge for duplicate prevention', () => { + it('should allow parallel edges (multiple edges between same nodes)', () => { const { addEdge } = useGraphStore.getState(); - // Add same edge twice - addEdge(createMockEdge('edge-1', 'node-1', 'node-2')); + // Add two edges between same nodes with different IDs addEdge(createMockEdge('edge-1', 'node-1', 'node-2')); + addEdge(createMockEdge('edge-2', 'node-1', 'node-2')); const state = useGraphStore.getState(); - // React Flow's addEdge should prevent duplicates - expect(state.edges.length).toBeGreaterThan(0); + // Should allow parallel edges (no deduplication) + expect(state.edges).toHaveLength(2); + expect(state.edges[0].id).toBe('edge-1'); + expect(state.edges[1].id).toBe('edge-2'); }); }); diff --git a/src/utils/edgeUtils.test.ts b/src/utils/edgeUtils.test.ts new file mode 100644 index 0000000..d1a1321 --- /dev/null +++ b/src/utils/edgeUtils.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from 'vitest'; +import { + calculateEdgeOffsetMultiplier, + calculatePerpendicularOffset, + groupParallelEdges, + generateEdgeId, +} from './edgeUtils'; +import type { Relation } from '../types'; + +describe('edgeUtils', () => { + describe('generateEdgeId', () => { + it('should generate unique IDs for same source/target', () => { + const id1 = generateEdgeId('node-1', 'node-2'); + const id2 = generateEdgeId('node-1', 'node-2'); + + expect(id1).not.toBe(id2); + expect(id1).toMatch(/^edge_node-1_node-2_[0-9a-f-]{36}$/); + expect(id2).toMatch(/^edge_node-1_node-2_[0-9a-f-]{36}$/); + }); + + it('should include source and target in ID for readability', () => { + const id = generateEdgeId('actor-123', 'actor-456'); + + expect(id).toContain('actor-123'); + expect(id).toContain('actor-456'); + }); + }); + + describe('calculateEdgeOffsetMultiplier', () => { + it('should return 0 for single edge', () => { + expect(calculateEdgeOffsetMultiplier(0, 1)).toBe(0); + }); + + it('should return ±0.5 for 2 edges', () => { + expect(calculateEdgeOffsetMultiplier(0, 2)).toBe(-0.5); + expect(calculateEdgeOffsetMultiplier(1, 2)).toBe(0.5); + }); + + it('should return -1, 0, 1 for 3 edges', () => { + expect(calculateEdgeOffsetMultiplier(0, 3)).toBe(-1); + expect(calculateEdgeOffsetMultiplier(1, 3)).toBe(0); + expect(calculateEdgeOffsetMultiplier(2, 3)).toBe(1); + }); + + it('should return -1.5, -0.5, 0.5, 1.5 for 4 edges', () => { + expect(calculateEdgeOffsetMultiplier(0, 4)).toBe(-1.5); + expect(calculateEdgeOffsetMultiplier(1, 4)).toBe(-0.5); + expect(calculateEdgeOffsetMultiplier(2, 4)).toBe(0.5); + expect(calculateEdgeOffsetMultiplier(3, 4)).toBe(1.5); + }); + + it('should return -2, -1, 0, 1, 2 for 5 edges', () => { + expect(calculateEdgeOffsetMultiplier(0, 5)).toBe(-2); + expect(calculateEdgeOffsetMultiplier(1, 5)).toBe(-1); + expect(calculateEdgeOffsetMultiplier(2, 5)).toBe(0); + expect(calculateEdgeOffsetMultiplier(3, 5)).toBe(1); + expect(calculateEdgeOffsetMultiplier(4, 5)).toBe(2); + }); + + it('should distribute symmetrically around center for any count', () => { + // Test with 6 edges + const offsets = [0, 1, 2, 3, 4, 5].map(i => calculateEdgeOffsetMultiplier(i, 6)); + const sum = offsets.reduce((a, b) => a + b, 0); + + // Sum should be 0 (symmetric distribution) + expect(sum).toBe(0); + }); + }); + + describe('calculatePerpendicularOffset', () => { + it('should calculate perpendicular for horizontal line (should be vertical)', () => { + const result = calculatePerpendicularOffset(0, 0, 100, 0, 50); + + expect(result.x).toBeCloseTo(0, 10); + expect(result.y).toBeCloseTo(50, 10); + }); + + it('should calculate perpendicular for vertical line (should be horizontal)', () => { + const result = calculatePerpendicularOffset(0, 0, 0, 100, 50); + + expect(result.x).toBeCloseTo(-50, 10); + expect(result.y).toBeCloseTo(0, 10); + }); + + it('should calculate perpendicular for diagonal line (45 degrees)', () => { + const result = calculatePerpendicularOffset(0, 0, 100, 100, 50); + + // For 45° line, perpendicular should be at -45° + // Components should be equal in magnitude, opposite signs + expect(Math.abs(result.x)).toBeCloseTo(Math.abs(result.y), 5); + expect(result.x).toBeLessThan(0); + expect(result.y).toBeGreaterThan(0); + }); + + it('should handle zero-distance case', () => { + const result = calculatePerpendicularOffset(50, 50, 50, 50, 30); + + expect(result.x).toBeCloseTo(30, 10); + expect(result.y).toBeCloseTo(0, 10); + }); + + it('should handle negative offset magnitude', () => { + const result = calculatePerpendicularOffset(0, 0, 100, 0, -50); + + expect(result.x).toBeCloseTo(0, 10); + expect(result.y).toBeCloseTo(-50, 10); + }); + + it('should scale proportionally with magnitude', () => { + const result1 = calculatePerpendicularOffset(0, 0, 100, 0, 25); + const result2 = calculatePerpendicularOffset(0, 0, 100, 0, 50); + + expect(result2.y).toBe(result1.y * 2); + }); + + it('should produce unit vector when magnitude is line length', () => { + const distance = Math.sqrt(100 * 100 + 100 * 100); // ~141.42 + const result = calculatePerpendicularOffset(0, 0, 100, 100, distance); + + // Perpendicular should have same length as the line + const resultLength = Math.sqrt(result.x * result.x + result.y * result.y); + expect(resultLength).toBeCloseTo(distance, 5); + }); + }); + + describe('groupParallelEdges', () => { + const createMockEdge = (id: string, source: string, target: string): Relation => ({ + id, + source, + target, + type: 'custom', + data: { type: 'default' }, + }); + + it('should return empty map when no parallel edges exist', () => { + const edges = [ + createMockEdge('e1', 'n1', 'n2'), + createMockEdge('e2', 'n2', 'n3'), + createMockEdge('e3', 'n3', 'n4'), + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(0); + }); + + it('should group two edges in same direction', () => { + const edges = [ + createMockEdge('e1', 'n1', 'n2'), + createMockEdge('e2', 'n1', 'n2'), + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(1); + const group = Array.from(result.values())[0]; + expect(group.edges).toHaveLength(2); + expect(group.sourceId).toBe('n1'); + expect(group.targetId).toBe('n2'); + }); + + it('should group bidirectional edges (A→B and B→A)', () => { + const edges = [ + createMockEdge('e1', 'n1', 'n2'), + createMockEdge('e2', 'n2', 'n1'), + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(1); + const group = Array.from(result.values())[0]; + expect(group.edges).toHaveLength(2); + // Should use normalized (sorted) IDs + expect([group.sourceId, group.targetId].sort()).toEqual(['n1', 'n2']); + }); + + it('should handle multiple parallel groups', () => { + const edges = [ + createMockEdge('e1', 'n1', 'n2'), + createMockEdge('e2', 'n1', 'n2'), + createMockEdge('e3', 'n3', 'n4'), + createMockEdge('e4', 'n4', 'n3'), + createMockEdge('e5', 'n5', 'n6'), // Not parallel + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(2); + }); + + it('should use <-> separator to handle node IDs with underscores', () => { + const edges = [ + createMockEdge('e1', 'node_1_abc', 'node_2_def'), + createMockEdge('e2', 'node_1_abc', 'node_2_def'), + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(1); + const key = Array.from(result.keys())[0]; + expect(key).toContain('<->'); + expect(key.split('<->')).toHaveLength(2); + }); + + it('should handle three parallel edges in same direction', () => { + const edges = [ + createMockEdge('e1', 'n1', 'n2'), + createMockEdge('e2', 'n1', 'n2'), + createMockEdge('e3', 'n1', 'n2'), + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(1); + const group = Array.from(result.values())[0]; + expect(group.edges).toHaveLength(3); + }); + + it('should handle mixed bidirectional parallel edges', () => { + const edges = [ + createMockEdge('e1', 'n1', 'n2'), + createMockEdge('e2', 'n1', 'n2'), + createMockEdge('e3', 'n2', 'n1'), + createMockEdge('e4', 'n2', 'n1'), + ]; + + const result = groupParallelEdges(edges); + + expect(result.size).toBe(1); + const group = Array.from(result.values())[0]; + expect(group.edges).toHaveLength(4); + }); + + it('should normalize group key regardless of edge direction', () => { + const edges1 = [ + createMockEdge('e1', 'n1', 'n2'), + createMockEdge('e2', 'n1', 'n2'), + ]; + + const edges2 = [ + createMockEdge('e1', 'n2', 'n1'), + createMockEdge('e2', 'n2', 'n1'), + ]; + + const result1 = groupParallelEdges(edges1); + const result2 = groupParallelEdges(edges2); + + const key1 = Array.from(result1.keys())[0]; + const key2 = Array.from(result2.keys())[0]; + + expect(key1).toBe(key2); // Should produce same normalized key + }); + }); +}); diff --git a/src/utils/edgeUtils.ts b/src/utils/edgeUtils.ts index 93fbedb..a828aee 100644 --- a/src/utils/edgeUtils.ts +++ b/src/utils/edgeUtils.ts @@ -3,10 +3,11 @@ import type { Node } from '@xyflow/react'; import { ROUNDED_RECTANGLE_RADIUS } from '../constants'; /** - * Generates a unique ID for edges + * Generates a unique ID for edges using crypto.randomUUID() + * Format: edge___ for guaranteed uniqueness and readability */ export const generateEdgeId = (source: string, target: string): string => { - return `edge_${source}_${target}_${Date.now()}`; + return `edge_${source}_${target}_${crypto.randomUUID()}`; }; /**