diff --git a/CONNECTION_TEST_PLAN.md b/CONNECTION_TEST_PLAN.md new file mode 100644 index 0000000..c6b3a1c --- /dev/null +++ b/CONNECTION_TEST_PLAN.md @@ -0,0 +1,187 @@ +# Connection 1:1 Constraint Test Plan + +## Issue Description +Two purple arrows (or multiple arrows) coming from the same connection point on an idea card. Each handle should support exactly ONE connection (1:1 constraint). + +## Implementation Details + +### Core Logic +The `validateAndRemoveConflicts` function enforces the 1:1 constraint: +- For any new connection, it checks all existing edges +- Removes edges that conflict with the source handle OR target handle +- Returns filtered edges, count of removed edges, and conflict descriptions + +### Key Functions Modified +1. **onConnect** - Creates new connections with conflict resolution +2. **onReconnect** - Remaps existing connections with conflict resolution +3. **validateAndRemoveConflicts** - Core validation logic (new) + +## Test Cases + +### Test Case 1: Basic Connection Creation +**Steps:** +1. Go to Feature Idea Cloud +2. Click the 🔍 Debug button to open the debug panel +3. Drag from Idea A's right handle to Idea B's left handle +4. Verify in debug panel that: + - Idea A's right handle shows ✓ (occupied) + - Idea B's left handle shows ✓ (occupied) + - Total edges increased by 1 + +**Expected Result:** Connection created successfully, both handles marked as occupied + +### Test Case 2: Prevent Multiple Connections from Same Source Handle +**Steps:** +1. Create connection: Idea A[right] → Idea B[left] +2. Verify connection exists in debug panel +3. Try to create: Idea A[right] → Idea C[left] +4. Check debug panel and toast notification + +**Expected Result:** +- Toast shows "Connection remapped! (1 old connection removed)" +- Idea A's right handle now connects to Idea C only +- Old connection to Idea B is removed +- Debug panel shows only 1 connection from Idea A's right handle + +### Test Case 3: Prevent Multiple Connections to Same Target Handle +**Steps:** +1. Create connection: Idea A[right] → Idea B[left] +2. Try to create: Idea C[right] → Idea B[left] +3. Check debug panel + +**Expected Result:** +- Toast shows "Connection remapped! (1 old connection removed)" +- Idea B's left handle now connects from Idea C only +- Old connection from Idea A is removed +- Debug panel shows only 1 connection to Idea B's left handle + +### Test Case 4: Reconnection (Remapping) from Source +**Steps:** +1. Create connection: Idea A[right] → Idea B[left] +2. Drag the source end (at Idea A) to Idea A's bottom handle +3. Check debug panel + +**Expected Result:** +- Connection now goes from Idea A[bottom] → Idea B[left] +- Idea A's right handle is now free (○) +- Idea A's bottom handle is now occupied (✓) +- Toast shows "Connection remapped!" + +### Test Case 5: Reconnection (Remapping) to Different Target +**Steps:** +1. Create connection: Idea A[right] → Idea B[left] +2. Drag the target end (at Idea B) to Idea C's left handle +3. Check debug panel + +**Expected Result:** +- Connection now goes from Idea A[right] → Idea C[left] +- Idea B's left handle is now free (○) +- Idea C's left handle is now occupied (✓) + +### Test Case 6: Reconnection with Conflict Resolution +**Steps:** +1. Create connection 1: Idea A[right] → Idea B[left] +2. Create connection 2: Idea C[right] → Idea D[left] +3. Drag connection 2's target from Idea D to Idea B's left handle +4. Check debug panel + +**Expected Result:** +- Connection 1 is removed (conflict on Idea B's left handle) +- Connection 2 now goes: Idea C[right] → Idea B[left] +- Toast shows "Connection remapped! (1 conflicting connection removed)" +- Idea B's left handle shows only 1 connection total + +### Test Case 7: Database Persistence +**Steps:** +1. Create several connections with various conflict resolutions +2. Note the final state in the debug panel +3. Refresh the page (F5) +4. Open debug panel again + +**Expected Result:** +- All connections persist exactly as they were +- No duplicate connections on any handle +- Debug panel shows same state as before refresh + +### Test Case 8: Console Logging Verification +**Steps:** +1. Open browser DevTools console +2. Create a new connection +3. Look for log entries starting with `[Connection]` + +**Expected Result:** +``` +[Connection] New connection attempt: { source: "idea-X[right]", target: "idea-Y[left]" } +[Connection Validator] Conflicts detected and resolved: [...] // (if conflicts exist) +[Connection] New edge created: edge-123456789 +[Connection] Total edges after addition: N +[Connection] Edges by handle: [...] +``` + +### Test Case 9: Multiple Handles Per Node +**Steps:** +1. Create 4 different connections from a single idea using all 4 handles: + - Idea A[left] ← Idea B[right] + - Idea A[right] → Idea C[left] + - Idea A[top] ← Idea D[bottom] + - Idea A[bottom] → Idea E[top] +2. Check debug panel for Idea A + +**Expected Result:** +- All 4 handles show ✓ (occupied) +- Total connections for Idea A: 4 +- Each handle has exactly 1 connection +- No conflicts exist + +### Test Case 10: Edge Case - Same Source and Target +**Steps:** +1. Create connection: Idea A[right] → Idea B[left] +2. Create connection: Idea C[right] → Idea D[left] +3. Remap connection 2 to: Idea A[right] → Idea B[left] + +**Expected Result:** +- Connection 1 is removed (conflicts on BOTH source AND target) +- Connection 2 takes over both handles +- Toast shows "Connection remapped! (1 conflicting connection removed)" +- Only 1 arrow exists between Idea A and Idea B + +## Visual Indicators in Debug Panel + +The debug panel shows a grid for each idea card with 4 handle positions: +- `← ✓` or `← ○` (left handle - incoming) +- `→ ✓` or `→ ○` (right handle - outgoing) +- `↑ ✓` or `↑ ○` (top handle - incoming) +- `↓ ✓` or `↓ ○` (bottom handle - outgoing) + +Green background = Handle is occupied (✓) +Gray background = Handle is free (○) + +## Success Criteria + +✅ All test cases pass +✅ No multiple arrows from/to same connection point +✅ Automatic conflict resolution works correctly +✅ Changes persist to database +✅ Console logs provide clear debugging information +✅ Toast notifications inform user of remapping +✅ Debug panel accurately reflects connection state + +## How to Run Tests + +1. Navigate to Feature Idea Cloud page in the app +2. Click the 🔍 debug icon in the top-right panel +3. Follow each test case step-by-step +4. Verify expected results using: + - Visual inspection of arrows on canvas + - Debug panel handle occupancy display + - Toast notifications + - Browser console logs +5. Test persistence by refreshing the page + +## Notes for Developer + +- The debug panel is ONLY for testing and can be removed once all tests pass +- All console.log statements with `[Connection]` and `[Reconnection]` prefixes are for debugging +- The validateAndRemoveConflicts function is the core of the 1:1 constraint +- Each handle ID is either 'left', 'right', 'top', 'bottom', or 'default' +- The logic treats missing sourceHandle/targetHandle as 'default' diff --git a/src/components/FeatureIdeaCloud.tsx b/src/components/FeatureIdeaCloud.tsx index 1af8d52..20ff0eb 100644 --- a/src/components/FeatureIdeaCloud.tsx +++ b/src/components/FeatureIdeaCloud.tsx @@ -26,6 +26,7 @@ import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { ScrollArea } from '@/components/ui/scroll-area' import { Plus, Trash, Sparkle, DotsThree, Package } from '@phosphor-icons/react' import { toast } from 'sonner' @@ -348,6 +349,7 @@ export function FeatureIdeaCloud() { const [groupDialogOpen, setGroupDialogOpen] = useState(false) const [viewDialogOpen, setViewDialogOpen] = useState(false) const [edgeDialogOpen, setEdgeDialogOpen] = useState(false) + const [debugPanelOpen, setDebugPanelOpen] = useState(false) const [connectionType, setConnectionType] = useState('association') const edgeReconnectSuccessful = useRef(true) @@ -452,6 +454,52 @@ export function FeatureIdeaCloud() { [onEdgesChange, setEdges, setSavedEdges] ) + const validateAndRemoveConflicts = useCallback(( + edges: Edge[], + sourceNodeId: string, + sourceHandleId: string, + targetNodeId: string, + targetHandleId: string, + excludeEdgeId?: string + ): { filteredEdges: Edge[], removedCount: number, conflicts: string[] } => { + const edgesToRemove: string[] = [] + const conflicts: string[] = [] + + edges.forEach(edge => { + if (excludeEdgeId && edge.id === excludeEdgeId) return + + const edgeSourceHandle = edge.sourceHandle || 'default' + const edgeTargetHandle = edge.targetHandle || 'default' + + const hasSourceConflict = edge.source === sourceNodeId && edgeSourceHandle === sourceHandleId + const hasTargetConflict = edge.target === targetNodeId && edgeTargetHandle === targetHandleId + + if (hasSourceConflict) { + edgesToRemove.push(edge.id) + conflicts.push(`Source conflict: ${edge.source}[${edgeSourceHandle}] -> ${edge.target}[${edgeTargetHandle}]`) + } + + if (hasTargetConflict) { + if (!edgesToRemove.includes(edge.id)) { + edgesToRemove.push(edge.id) + } + conflicts.push(`Target conflict: ${edge.source}[${edgeSourceHandle}] -> ${edge.target}[${edgeTargetHandle}]`) + } + }) + + const filteredEdges = edges.filter(e => !edgesToRemove.includes(e.id)) + + if (conflicts.length > 0) { + console.log('[Connection Validator] Conflicts detected and resolved:', conflicts) + } + + return { + filteredEdges, + removedCount: edgesToRemove.length, + conflicts + } + }, []) + const onConnect = useCallback( (params: RFConnection) => { if (!params.source || !params.target) return @@ -461,22 +509,19 @@ export function FeatureIdeaCloud() { const targetNodeId = params.target const targetHandleId = params.targetHandle || 'default' + console.log('[Connection] New connection attempt:', { + source: `${sourceNodeId}[${sourceHandleId}]`, + target: `${targetNodeId}[${targetHandleId}]` + }) + setEdges((eds) => { - const edgesToRemove: string[] = [] - - eds.forEach(edge => { - const edgeSourceHandle = edge.sourceHandle || 'default' - const edgeTargetHandle = edge.targetHandle || 'default' - - const hasSourceConflict = edge.source === sourceNodeId && edgeSourceHandle === sourceHandleId - const hasTargetConflict = edge.target === targetNodeId && edgeTargetHandle === targetHandleId - - if (hasSourceConflict || hasTargetConflict) { - edgesToRemove.push(edge.id) - } - }) - - const filteredEdges = eds.filter(e => !edgesToRemove.includes(e.id)) + const { filteredEdges, removedCount, conflicts } = validateAndRemoveConflicts( + eds, + sourceNodeId, + sourceHandleId, + targetNodeId, + targetHandleId + ) const style = CONNECTION_STYLES[connectionType] const newEdge: Edge = { @@ -502,11 +547,22 @@ export function FeatureIdeaCloud() { } const updatedEdges = addEdge(newEdge, filteredEdges) + + console.log('[Connection] New edge created:', newEdge.id) + console.log('[Connection] Total edges after addition:', updatedEdges.length) + console.log('[Connection] Edges by handle:', updatedEdges.map(e => ({ + id: e.id, + source: `${e.source}[${e.sourceHandle || 'default'}]`, + target: `${e.target}[${e.targetHandle || 'default'}]` + }))) + setSavedEdges(updatedEdges) - if (edgesToRemove.length > 0) { + if (removedCount > 0) { setTimeout(() => { - toast.success(`Connection remapped! (${edgesToRemove.length} old connection${edgesToRemove.length > 1 ? 's' : ''} removed)`) + toast.success(`Connection remapped! (${removedCount} old connection${removedCount > 1 ? 's' : ''} removed)`, { + description: conflicts.join('\n') + }) }, 0) } else { setTimeout(() => { @@ -517,7 +573,7 @@ export function FeatureIdeaCloud() { return updatedEdges }) }, - [connectionType, setEdges, setSavedEdges] + [connectionType, setEdges, setSavedEdges, validateAndRemoveConflicts] ) const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => { @@ -542,32 +598,43 @@ export function FeatureIdeaCloud() { const targetNodeId = newConnection.target const targetHandleId = newConnection.targetHandle || 'default' + console.log('[Reconnection] Remapping edge:', { + oldEdgeId: oldEdge.id, + oldSource: `${oldEdge.source}[${oldEdge.sourceHandle || 'default'}]`, + oldTarget: `${oldEdge.target}[${oldEdge.targetHandle || 'default'}]`, + newSource: `${sourceNodeId}[${sourceHandleId}]`, + newTarget: `${targetNodeId}[${targetHandleId}]` + }) + edgeReconnectSuccessful.current = true setEdges((els) => { - const edgesToRemove: string[] = [] + const { filteredEdges, removedCount, conflicts } = validateAndRemoveConflicts( + els, + sourceNodeId, + sourceHandleId, + targetNodeId, + targetHandleId, + oldEdge.id + ) - els.forEach(edge => { - if (edge.id === oldEdge.id) return - - const edgeSourceHandle = edge.sourceHandle || 'default' - const edgeTargetHandle = edge.targetHandle || 'default' - - const hasSourceConflict = edge.source === sourceNodeId && edgeSourceHandle === sourceHandleId - const hasTargetConflict = edge.target === targetNodeId && edgeTargetHandle === targetHandleId - - if (hasSourceConflict || hasTargetConflict) { - edgesToRemove.push(edge.id) - } - }) - - const filteredEdges = els.filter(e => !edgesToRemove.includes(e.id)) const updatedEdges = reconnectEdge(oldEdge, newConnection, filteredEdges) + + console.log('[Reconnection] Edge remapped successfully') + console.log('[Reconnection] Total edges after remapping:', updatedEdges.length) + console.log('[Reconnection] Edges by handle:', updatedEdges.map(e => ({ + id: e.id, + source: `${e.source}[${e.sourceHandle || 'default'}]`, + target: `${e.target}[${e.targetHandle || 'default'}]` + }))) + setSavedEdges(updatedEdges) - if (edgesToRemove.length > 0) { + if (removedCount > 0) { setTimeout(() => { - toast.success(`Connection remapped! (${edgesToRemove.length} conflicting connection${edgesToRemove.length > 1 ? 's' : ''} removed)`) + toast.success(`Connection remapped! (${removedCount} conflicting connection${removedCount > 1 ? 's' : ''} removed)`, { + description: conflicts.join('\n') + }) }, 0) } else { setTimeout(() => { @@ -577,7 +644,7 @@ export function FeatureIdeaCloud() { return updatedEdges }) - }, [setEdges, setSavedEdges]) + }, [setEdges, setSavedEdges, validateAndRemoveConflicts]) const onReconnectEnd = useCallback((_: MouseEvent | TouchEvent, edge: Edge) => { if (!edgeReconnectSuccessful.current) { @@ -875,6 +942,20 @@ export function FeatureIdeaCloud() { + + + + + Debug Connection Status + + + + +
+
+
Total Edges
+
{edges.length}
+
+
+
Total Nodes
+
{nodes.length}
+
+
+
Total Ideas
+
{safeIdeas.length}
+
+
+ +
+
+ Connection Matrix (Handle Occupancy) + 1:1 Constraint Active +
+ +
+ {safeIdeas.slice(0, 10).map((idea) => { + const nodeEdges = edges.filter(e => e.source === idea.id || e.target === idea.id) + const leftHandle = edges.find(e => e.target === idea.id && e.targetHandle === 'left') + const rightHandle = edges.find(e => e.source === idea.id && e.sourceHandle === 'right') + const topHandle = edges.find(e => e.target === idea.id && e.targetHandle === 'top') + const bottomHandle = edges.find(e => e.source === idea.id && e.sourceHandle === 'bottom') + + return ( +
+
+ {idea.title} +
+
+
+ ← {leftHandle ? '✓' : '○'} +
+
+ → {rightHandle ? '✓' : '○'} +
+
+ ↑ {topHandle ? '✓' : '○'} +
+
+ ↓ {bottomHandle ? '✓' : '○'} +
+
+
+ Total: {nodeEdges.length} connection{nodeEdges.length !== 1 ? 's' : ''} +
+
+ ) + })} + {safeIdeas.length > 10 && ( +
+ ... and {safeIdeas.length - 10} more ideas +
+ )} +
+
+
+ +
+
+ ✅ Test Status +
+
+
• Each handle can connect to exactly 1 other handle
+
• New connections automatically remove conflicts
+
• Remapping preserves 1:1 constraint
+
• Changes persist to database immediately
+
+
+ + +
+ )}