Browse Source

test(17-01): add BaseNode component tests

- 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
shrimbly 6 months ago
parent
commit
93d30033b3
  1. 564
      src/components/__tests__/BaseNode.test.tsx

564
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 <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…
Cancel
Save