diff --git a/src/components/__tests__/BaseNode.test.tsx b/src/components/__tests__/BaseNode.test.tsx new file mode 100644 index 00000000..8101ae0a --- /dev/null +++ b/src/components/__tests__/BaseNode.test.tsx @@ -0,0 +1,564 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { BaseNode } from "@/components/nodes/BaseNode"; +import { ReactFlowProvider } from "@xyflow/react"; + +// Mock the workflow store +const mockUseWorkflowStore = vi.fn(); + +vi.mock("@/store/workflowStore", () => ({ + useWorkflowStore: (selector: (state: unknown) => unknown) => mockUseWorkflowStore(selector), +})); + +// Mock useReactFlow +const mockGetNodes = vi.fn(() => []); +const mockSetNodes = vi.fn(); + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + useReactFlow: () => ({ + getNodes: mockGetNodes, + setNodes: mockSetNodes, + }), + }; +}); + +// Wrapper component for React Flow context +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("BaseNode", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementation + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + currentNodeId: null, + groups: {}, + nodes: [], + }; + return selector(state); + }); + }); + + const defaultProps = { + id: "test-node-1", + title: "Test Node", + children:
Test Content
, + }; + + describe("Basic Rendering", () => { + it("should render the title", () => { + render( + + + + ); + + expect(screen.getByText("Test Node")).toBeInTheDocument(); + }); + + it("should render children content", () => { + render( + + + + ); + + expect(screen.getByTestId("test-children")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + it("should apply custom className", () => { + const { container } = render( + + + + ); + + const nodeDiv = container.querySelector(".custom-class"); + expect(nodeDiv).toBeInTheDocument(); + }); + + it("should render custom title with default title", () => { + render( + + + + ); + + expect(screen.getByText("My Custom Name - Test Node")).toBeInTheDocument(); + }); + + it("should render titlePrefix when provided", () => { + render( + + PREFIX} + /> + + ); + + expect(screen.getByTestId("title-prefix")).toBeInTheDocument(); + }); + + it("should render headerAction when provided", () => { + render( + + Action} + /> + + ); + + expect(screen.getByTestId("header-action")).toBeInTheDocument(); + }); + }); + + describe("Title Editing", () => { + it("should enter edit mode when title is clicked", () => { + render( + + + + ); + + const title = screen.getByText("Test Node"); + fireEvent.click(title); + + const input = screen.getByPlaceholderText("Custom title..."); + expect(input).toBeInTheDocument(); + }); + + it("should update title value when typing", () => { + render( + + + + ); + + const title = screen.getByText("Test Node"); + fireEvent.click(title); + + const input = screen.getByPlaceholderText("Custom title..."); + fireEvent.change(input, { target: { value: "New Title" } }); + + expect(input).toHaveValue("New Title"); + }); + + it("should submit title on Enter and call onCustomTitleChange", () => { + const mockOnCustomTitleChange = vi.fn(); + render( + + + + ); + + const title = screen.getByText("Test Node"); + fireEvent.click(title); + + const input = screen.getByPlaceholderText("Custom title..."); + fireEvent.change(input, { target: { value: "New Title" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mockOnCustomTitleChange).toHaveBeenCalledWith("New Title"); + }); + + it("should cancel editing on Escape and revert to original value", () => { + render( + + + + ); + + const title = screen.getByText("Original - Test Node"); + fireEvent.click(title); + + const input = screen.getByPlaceholderText("Custom title..."); + fireEvent.change(input, { target: { value: "Changed" } }); + fireEvent.keyDown(input, { key: "Escape" }); + + // Should exit edit mode and show original title + expect(screen.getByText("Original - Test Node")).toBeInTheDocument(); + }); + + it("should submit title on blur", () => { + const mockOnCustomTitleChange = vi.fn(); + render( + + + + ); + + const title = screen.getByText("Test Node"); + fireEvent.click(title); + + const input = screen.getByPlaceholderText("Custom title..."); + fireEvent.change(input, { target: { value: "Blurred Title" } }); + fireEvent.blur(input); + + expect(mockOnCustomTitleChange).toHaveBeenCalledWith("Blurred Title"); + }); + }); + + describe("Comment Functionality", () => { + it("should render empty comment button when no comment exists", () => { + render( + + + + ); + + const commentButton = screen.getByTitle("Add comment"); + expect(commentButton).toBeInTheDocument(); + }); + + it("should render filled comment button when comment exists", () => { + render( + + + + ); + + const commentButton = screen.getByTitle("Edit comment"); + expect(commentButton).toBeInTheDocument(); + }); + + it("should open comment popover when button is clicked", () => { + render( + + + + ); + + const commentButton = screen.getByTitle("Add comment"); + fireEvent.click(commentButton); + + const textarea = screen.getByPlaceholderText("Add a comment..."); + expect(textarea).toBeInTheDocument(); + }); + + it("should show existing comment in textarea", () => { + render( + + + + ); + + const commentButton = screen.getByTitle("Edit comment"); + fireEvent.click(commentButton); + + const textarea = screen.getByPlaceholderText("Add a comment..."); + expect(textarea).toHaveValue("Existing comment"); + }); + + it("should call onCommentChange when Save is clicked", () => { + const mockOnCommentChange = vi.fn(); + render( + + + + ); + + const commentButton = screen.getByTitle("Add comment"); + fireEvent.click(commentButton); + + const textarea = screen.getByPlaceholderText("Add a comment..."); + fireEvent.change(textarea, { target: { value: "New comment" } }); + + const saveButton = screen.getByText("Save"); + fireEvent.click(saveButton); + + expect(mockOnCommentChange).toHaveBeenCalledWith("New comment"); + }); + + it("should close popover and revert on Cancel", () => { + const mockOnCommentChange = vi.fn(); + render( + + + + ); + + const commentButton = screen.getByTitle("Edit comment"); + fireEvent.click(commentButton); + + const textarea = screen.getByPlaceholderText("Add a comment..."); + fireEvent.change(textarea, { target: { value: "Changed" } }); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + // Popover should be closed + expect(screen.queryByPlaceholderText("Add a comment...")).not.toBeInTheDocument(); + expect(mockOnCommentChange).not.toHaveBeenCalled(); + }); + + it("should close popover on Escape key", () => { + render( + + + + ); + + const commentButton = screen.getByTitle("Add comment"); + fireEvent.click(commentButton); + + const textarea = screen.getByPlaceholderText("Add a comment..."); + fireEvent.keyDown(textarea, { key: "Escape" }); + + expect(screen.queryByPlaceholderText("Add a comment...")).not.toBeInTheDocument(); + }); + }); + + describe("Expand Button", () => { + it("should not render expand button when onExpand is not provided", () => { + render( + + + + ); + + expect(screen.queryByTitle("Expand editor")).not.toBeInTheDocument(); + }); + + it("should render expand button when onExpand is provided", () => { + render( + + + + ); + + expect(screen.getByTitle("Expand editor")).toBeInTheDocument(); + }); + + it("should call onExpand when expand button is clicked", () => { + const mockOnExpand = vi.fn(); + render( + + + + ); + + const expandButton = screen.getByTitle("Expand editor"); + fireEvent.click(expandButton); + + expect(mockOnExpand).toHaveBeenCalled(); + }); + }); + + describe("Run Button", () => { + it("should not render run button when onRun is not provided", () => { + render( + + + + ); + + expect(screen.queryByTitle("Run this node")).not.toBeInTheDocument(); + }); + + it("should render run button when onRun is provided", () => { + render( + + + + ); + + expect(screen.getByTitle("Run this node")).toBeInTheDocument(); + }); + + it("should call onRun when run button is clicked", () => { + const mockOnRun = vi.fn(); + render( + + + + ); + + const runButton = screen.getByTitle("Run this node"); + fireEvent.click(runButton); + + expect(mockOnRun).toHaveBeenCalled(); + }); + + it("should disable run button when isExecuting is true", () => { + render( + + + + ); + + const runButton = screen.getByTitle("Run this node"); + expect(runButton).toBeDisabled(); + }); + + it("should not call onRun when button is disabled", () => { + const mockOnRun = vi.fn(); + render( + + + + ); + + const runButton = screen.getByTitle("Run this node"); + fireEvent.click(runButton); + + expect(mockOnRun).not.toHaveBeenCalled(); + }); + }); + + describe("Lock Badge", () => { + it("should not render lock badge when node is not in a locked group", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + currentNodeId: null, + groups: { "group-1": { id: "group-1", locked: false } }, + nodes: [{ id: "test-node-1", groupId: "group-1" }], + }; + return selector(state); + }); + + render( + + + + ); + + expect( + screen.queryByTitle("This node is in a locked group and will be skipped during execution") + ).not.toBeInTheDocument(); + }); + + it("should render lock badge when node is in a locked group", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + currentNodeId: null, + groups: { "group-1": { id: "group-1", locked: true } }, + nodes: [{ id: "test-node-1", groupId: "group-1" }], + }; + return selector(state); + }); + + render( + + + + ); + + expect( + screen.getByTitle("This node is in a locked group and will be skipped during execution") + ).toBeInTheDocument(); + }); + + it("should not render lock badge when node has no groupId", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + currentNodeId: null, + groups: { "group-1": { id: "group-1", locked: true } }, + nodes: [{ id: "test-node-1" }], // No groupId + }; + return selector(state); + }); + + render( + + + + ); + + expect( + screen.queryByTitle("This node is in a locked group and will be skipped during execution") + ).not.toBeInTheDocument(); + }); + }); + + describe("Visual States", () => { + it("should apply selected styling when selected is true", () => { + const { container } = render( + + + + ); + + const nodeDiv = container.querySelector(".ring-2.ring-blue-500\\/40"); + expect(nodeDiv).toBeInTheDocument(); + }); + + it("should apply executing styling when isExecuting is true", () => { + const { container } = render( + + + + ); + + const nodeDiv = container.querySelector(".border-blue-500.ring-1"); + expect(nodeDiv).toBeInTheDocument(); + }); + + it("should apply executing styling when currentNodeId matches", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + currentNodeId: "test-node-1", + groups: {}, + nodes: [], + }; + return selector(state); + }); + + const { container } = render( + + + + ); + + const nodeDiv = container.querySelector(".border-blue-500.ring-1"); + expect(nodeDiv).toBeInTheDocument(); + }); + + it("should apply error styling when hasError is true", () => { + const { container } = render( + + + + ); + + const nodeDiv = container.querySelector(".border-red-500"); + expect(nodeDiv).toBeInTheDocument(); + }); + }); + + describe("Node Resizer", () => { + it("should make resizer visible when selected", () => { + const { container } = render( + + + + ); + + // NodeResizer component renders with isVisible prop + // We can check that the component was rendered by looking for resizer handles + const resizer = container.querySelector(".react-flow__resize-control"); + // NodeResizer is visible when selected, component structure varies + expect(container.firstChild).toBeTruthy(); + }); + + it("should use custom minWidth and minHeight", () => { + // This test verifies the props are passed, but the actual resizer behavior + // is managed by React Flow's NodeResizer component + render( + + + + ); + + // Component renders without error with custom dimensions + expect(screen.getByText("Test Node")).toBeInTheDocument(); + }); + }); +});