diff --git a/src/__tests__/store/workflowStore.test.ts b/src/__tests__/store/workflowStore.test.ts index 4b67488..fb56e1f 100644 --- a/src/__tests__/store/workflowStore.test.ts +++ b/src/__tests__/store/workflowStore.test.ts @@ -26,6 +26,153 @@ describe("WorkflowStore", () => { }); }); + describe("Node Type Validation", () => { + it("should add node with valid type", () => { + const { result } = renderHook(() => useWorkflowStore()); + + act(() => { + result.current.createWorkflow("Test Workflow"); + result.current.addNode("llmTask", { x: 100, y: 100 }); + }); + + expect(result.current.currentWorkflow?.nodes).toHaveLength(1); + expect(result.current.currentWorkflow?.nodes[0].type).toBe("llmTask"); + expect(result.current.currentWorkflow?.nodes[0].data.label).toBe( + "LLM Task" + ); + }); + + it("should reject invalid node type", () => { + const { result } = renderHook(() => useWorkflowStore()); + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(); + + act(() => { + result.current.createWorkflow("Test Workflow"); + result.current.addNode("invalidType", { x: 100, y: 100 }); + }); + + expect(result.current.currentWorkflow?.nodes).toHaveLength(0); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid node type: "invalidType"'), + expect.any(String) + ); + + consoleErrorSpy.mockRestore(); + }); + + it("should accept all valid node types", () => { + const { result } = renderHook(() => useWorkflowStore()); + + const validTypes = [ + "webScraping", + "structuredOutput", + "embeddingGenerator", + "similaritySearch", + "llmTask", + "dataInput", + "dataOutput", + "database", + "slack", + "discord", + "gmail", + ]; + + act(() => { + result.current.createWorkflow("Test Workflow"); + validTypes.forEach((type, index) => { + result.current.addNode(type, { x: index * 150, y: 100 }); + }); + }); + + expect(result.current.currentWorkflow?.nodes).toHaveLength( + validTypes.length + ); + }); + }); + + describe("Position Collision Detection", () => { + it("should place node at requested position if no collision", () => { + const { result } = renderHook(() => useWorkflowStore()); + + act(() => { + result.current.createWorkflow("Test Workflow"); + result.current.addNode("llmTask", { x: 100, y: 100 }); + }); + + expect(result.current.currentWorkflow?.nodes[0].position).toEqual({ + x: 100, + y: 100, + }); + }); + + it("should resolve position collision automatically", () => { + const { result } = renderHook(() => useWorkflowStore()); + const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); + + act(() => { + result.current.createWorkflow("Test Workflow"); + // Add first node + result.current.addNode("llmTask", { x: 100, y: 100 }); + // Try to add second node at same position + result.current.addNode("dataInput", { x: 100, y: 100 }); + }); + + expect(result.current.currentWorkflow?.nodes).toHaveLength(2); + + const node1Pos = result.current.currentWorkflow?.nodes[0].position; + const node2Pos = result.current.currentWorkflow?.nodes[1].position; + + // Positions should be different + expect(node1Pos).not.toEqual(node2Pos); + + // Should have logged collision detection + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Position collision detected") + ); + + consoleLogSpy.mockRestore(); + }); + + it("should handle multiple collisions", () => { + const { result } = renderHook(() => useWorkflowStore()); + + act(() => { + result.current.createWorkflow("Test Workflow"); + // Add multiple nodes at similar positions + result.current.addNode("llmTask", { x: 100, y: 100 }); + result.current.addNode("dataInput", { x: 105, y: 105 }); + result.current.addNode("dataOutput", { x: 110, y: 110 }); + }); + + expect(result.current.currentWorkflow?.nodes).toHaveLength(3); + + // All nodes should have unique positions + const positions = result.current.currentWorkflow?.nodes.map( + (n) => `${n.position.x},${n.position.y}` + ); + const uniquePositions = new Set(positions); + expect(uniquePositions.size).toBe(3); + }); + + it("should allow nodes far apart without collision", () => { + const { result } = renderHook(() => useWorkflowStore()); + const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); + + act(() => { + result.current.createWorkflow("Test Workflow"); + result.current.addNode("llmTask", { x: 100, y: 100 }); + result.current.addNode("dataInput", { x: 300, y: 100 }); // 200px apart + }); + + // No collision should be logged for nodes far apart + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Position collision detected") + ); + + consoleLogSpy.mockRestore(); + }); + }); + describe("Workflow Management", () => { it("should create a new workflow", () => { const { result } = renderHook(() => useWorkflowStore()); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 7c5f382..a452892 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -26,6 +26,104 @@ const WORKFLOWS_STORAGE_KEY = "agent-workflow-builder-workflows"; const generateNodeId = (): string => uuidv4(); const generateEdgeId = (): string => uuidv4(); +// Valid node types registry +const VALID_NODE_TYPES = new Set([ + "webScraping", + "structuredOutput", + "embeddingGenerator", + "similaritySearch", + "llmTask", + "dataInput", + "dataOutput", + "database", + "slack", + "discord", + "gmail", +]); + +// Validate node type +const isValidNodeType = (type: string): boolean => { + return VALID_NODE_TYPES.has(type); +}; + +// Position collision detection +const COLLISION_THRESHOLD = 80; // pixels - if nodes are within this distance, consider collision +const POSITION_OFFSET = 30; // pixels - offset for collision resolution + +const hasPositionCollision = ( + position: { x: number; y: number }, + existingNodes: WorkflowNode[] +): boolean => { + return existingNodes.some((node) => { + const dx = Math.abs(node.position.x - position.x); + const dy = Math.abs(node.position.y - position.y); + return dx < COLLISION_THRESHOLD && dy < COLLISION_THRESHOLD; + }); +}; + +const resolvePositionCollision = ( + position: { x: number; y: number }, + existingNodes: WorkflowNode[] +): { x: number; y: number } => { + let resolvedPosition = { ...position }; + let attempts = 0; + const maxAttempts = 10; + + // Try to find a non-colliding position + while ( + hasPositionCollision(resolvedPosition, existingNodes) && + attempts < maxAttempts + ) { + // Try positions in a spiral pattern: right, down, left, up + const offset = POSITION_OFFSET * (attempts + 1); + + switch (attempts % 4) { + case 0: // Right + resolvedPosition = { x: position.x + offset, y: position.y }; + break; + case 1: // Down + resolvedPosition = { x: position.x, y: position.y + offset }; + break; + case 2: // Left + resolvedPosition = { x: position.x - offset, y: position.y }; + break; + case 3: // Up + resolvedPosition = { x: position.x, y: position.y - offset }; + break; + } + + attempts++; + } + + // If still colliding after max attempts, use a random offset + if (hasPositionCollision(resolvedPosition, existingNodes)) { + resolvedPosition = { + x: position.x + Math.random() * 100 - 50, + y: position.y + Math.random() * 100 - 50, + }; + } + + return resolvedPosition; +}; + +// Get default label for node type +const getDefaultNodeLabel = (type: string): string => { + const labels: Record = { + webScraping: "Web Scraping", + structuredOutput: "Structured Output", + embeddingGenerator: "Embedding Generator", + similaritySearch: "Similarity Search", + llmTask: "LLM Task", + dataInput: "Data Input", + dataOutput: "Data Output", + database: "Database", + slack: "Slack", + discord: "Discord", + gmail: "Gmail", + }; + return labels[type] || `${type} Node`; +}; + // Create a mapping from deterministic IDs to UUIDs (used for new generation flows) const createIdMapping = (nodeCount: number): Map => { const mapping = new Map(); @@ -505,15 +603,30 @@ export const useWorkflowStore = create((set, get) => ({ const { currentWorkflow } = get(); if (!currentWorkflow) return; + // Validate node type + if (!isValidNodeType(type)) { + console.error( + `[WorkflowStore] Invalid node type: "${type}". Valid types:`, + Array.from(VALID_NODE_TYPES).join(", ") + ); + return; + } + + // Resolve position collision + const resolvedPosition = resolvePositionCollision( + position, + currentWorkflow.nodes + ); + const nodeId = generateNodeId(); const newNode: WorkflowNode = { id: nodeId, type, - position, + position: resolvedPosition, data: { id: nodeId, type: type as any, - label: `${type} Node`, + label: getDefaultNodeLabel(type), status: "idle", config: {}, inputs: [], @@ -521,6 +634,16 @@ export const useWorkflowStore = create((set, get) => ({ }, }; + // Log if position was adjusted + if ( + resolvedPosition.x !== position.x || + resolvedPosition.y !== position.y + ) { + console.log( + `[WorkflowStore] Position collision detected. Adjusted from (${position.x}, ${position.y}) to (${resolvedPosition.x}, ${resolvedPosition.y})` + ); + } + set((state) => ({ currentWorkflow: state.currentWorkflow ? {