Browse Source
- Add 35 comprehensive tests for BaseNode component - Test basic rendering (title, children, className, customTitle, titlePrefix, headerAction) - Test title editing flow (click to edit, typing, submit on Enter, cancel on Escape, blur) - Test comment functionality (add, edit, save, cancel, escape key) - Test expand button rendering and click handler - Test run button rendering, click handler, and disabled state - Test lock badge display for nodes in locked groups - Test visual states (selected, executing, error styling) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 564 additions and 0 deletions
@ -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 <ReactFlowProvider>{children}</ReactFlowProvider>; |
|||
} |
|||
|
|||
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: <div data-testid="test-children">Test Content</div>, |
|||
}; |
|||
|
|||
describe("Basic Rendering", () => { |
|||
it("should render the title", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByText("Test Node")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render children content", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTestId("test-children")).toBeInTheDocument(); |
|||
expect(screen.getByText("Test Content")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should apply custom className", () => { |
|||
const { container } = render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} className="custom-class" /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const nodeDiv = container.querySelector(".custom-class"); |
|||
expect(nodeDiv).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render custom title with default title", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} customTitle="My Custom Name" /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByText("My Custom Name - Test Node")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render titlePrefix when provided", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode |
|||
{...defaultProps} |
|||
titlePrefix={<span data-testid="title-prefix">PREFIX</span>} |
|||
/> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTestId("title-prefix")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render headerAction when provided", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode |
|||
{...defaultProps} |
|||
headerAction={<button data-testid="header-action">Action</button>} |
|||
/> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTestId("header-action")).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
describe("Title Editing", () => { |
|||
it("should enter edit mode when title is clicked", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCustomTitleChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCustomTitleChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCustomTitleChange={mockOnCustomTitleChange} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} customTitle="Original" onCustomTitleChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCustomTitleChange={mockOnCustomTitleChange} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCommentChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const commentButton = screen.getByTitle("Add comment"); |
|||
expect(commentButton).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render filled comment button when comment exists", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} comment="Test comment" onCommentChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const commentButton = screen.getByTitle("Edit comment"); |
|||
expect(commentButton).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should open comment popover when button is clicked", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCommentChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} comment="Existing comment" onCommentChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCommentChange={mockOnCommentChange} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} comment="Original" onCommentChange={mockOnCommentChange} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onCommentChange={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.queryByTitle("Expand editor")).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render expand button when onExpand is provided", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onExpand={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTitle("Expand editor")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should call onExpand when expand button is clicked", () => { |
|||
const mockOnExpand = vi.fn(); |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onExpand={mockOnExpand} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.queryByTitle("Run this node")).not.toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render run button when onRun is provided", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onRun={vi.fn()} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTitle("Run this node")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should call onRun when run button is clicked", () => { |
|||
const mockOnRun = vi.fn(); |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onRun={mockOnRun} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const runButton = screen.getByTitle("Run this node"); |
|||
fireEvent.click(runButton); |
|||
|
|||
expect(mockOnRun).toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("should disable run button when isExecuting is true", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onRun={vi.fn()} isExecuting={true} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const runButton = screen.getByTitle("Run this node"); |
|||
expect(runButton).toBeDisabled(); |
|||
}); |
|||
|
|||
it("should not call onRun when button is disabled", () => { |
|||
const mockOnRun = vi.fn(); |
|||
render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} onRun={mockOnRun} isExecuting={true} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} selected={true} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} isExecuting={true} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const nodeDiv = container.querySelector(".border-blue-500.ring-1"); |
|||
expect(nodeDiv).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should apply error styling when hasError is true", () => { |
|||
const { container } = render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} hasError={true} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const nodeDiv = container.querySelector(".border-red-500"); |
|||
expect(nodeDiv).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
describe("Node Resizer", () => { |
|||
it("should make resizer visible when selected", () => { |
|||
const { container } = render( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} selected={true} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
// 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( |
|||
<TestWrapper> |
|||
<BaseNode {...defaultProps} minWidth={200} minHeight={150} selected={true} /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
// Component renders without error with custom dimensions
|
|||
expect(screen.getByText("Test Node")).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue