Browse Source

test(17-02): add SplitGridNode and GroupNode component tests

SplitGridNode tests (23):
- Basic rendering with title, input/output handles
- Grid configuration display (rows x cols)
- Empty state and unconfigured warning
- Source image display with grid overlay
- Settings modal open/close and auto-open behavior
- Split button functionality and disabled states
- Child node count display
- Loading and error states
- Custom title editing

GroupNode tests (28):
- Basic rendering with group name and color
- Color picker button and delete button
- Group name editing (Enter, Escape, blur)
- Color picker open/close and color selection
- Delete group functionality
- Node resizer visibility based on selection
- Header drag functionality
- All 6 group colors (neutral, blue, green, purple, orange, red)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
1cbabc5a8f
  1. 468
      src/components/__tests__/GroupNode.test.tsx
  2. 397
      src/components/__tests__/SplitGridNode.test.tsx

468
src/components/__tests__/GroupNode.test.tsx

@ -0,0 +1,468 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { GroupNode } from "@/components/nodes/GroupNode";
import { ReactFlowProvider } from "@xyflow/react";
// Mock the workflow store
const mockUpdateGroup = vi.fn();
const mockDeleteGroup = vi.fn();
const mockMoveGroupNodes = vi.fn();
const mockUseWorkflowStore = vi.fn();
// Mock GROUP_COLORS
const mockGroupColors: Record<string, string> = {
neutral: "#262626",
blue: "#1e3a5f",
green: "#1a3d2e",
purple: "#2d2458",
orange: "#3d2a1a",
red: "#3d1a1a",
};
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: () => mockUseWorkflowStore(),
GROUP_COLORS: {
neutral: "#262626",
blue: "#1e3a5f",
green: "#1a3d2e",
purple: "#2d2458",
orange: "#3d2a1a",
red: "#3d1a1a",
},
}));
// Mock useReactFlow
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual("@xyflow/react");
return {
...actual,
useReactFlow: () => ({
getNodes: vi.fn(() => []),
setNodes: vi.fn(),
}),
NodeResizer: ({ isVisible, children }: { isVisible: boolean; children?: React.ReactNode }) => (
<div data-testid="node-resizer" data-visible={isVisible}>
{children}
</div>
),
};
});
// Wrapper component for React Flow context
function TestWrapper({ children }: { children: React.ReactNode }) {
return <ReactFlowProvider>{children}</ReactFlowProvider>;
}
describe("GroupNode", () => {
const defaultGroup = {
id: "group-1",
name: "Test Group",
color: "blue" as const,
position: { x: 0, y: 0 },
size: { width: 300, height: 200 },
locked: false,
};
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation
mockUseWorkflowStore.mockReturnValue({
groups: { "group-1": defaultGroup },
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
moveGroupNodes: mockMoveGroupNodes,
});
});
const createNodeProps = (data: { groupId: string } = { groupId: "group-1" }) => ({
id: "group-node-1",
type: "group" as const,
data,
selected: false,
});
describe("Basic Rendering", () => {
it("should render the group name", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("Test Group")).toBeInTheDocument();
});
it("should apply group color to background", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const groupContainer = container.querySelector(".rounded-xl");
expect(groupContainer).toBeInTheDocument();
// Browser converts hex to rgb, so check for the RGB values instead
// #1e3a5f = rgb(30, 58, 95)
expect(groupContainer?.getAttribute("style")).toContain("rgb(30, 58, 95)");
});
it("should render color picker button", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const colorButton = container.querySelector('button[title="Change color"]');
expect(colorButton).toBeInTheDocument();
});
it("should render delete button", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const deleteButton = container.querySelector('button[title="Delete group"]');
expect(deleteButton).toBeInTheDocument();
});
it("should return null when group not found", () => {
mockUseWorkflowStore.mockReturnValue({
groups: {},
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
moveGroupNodes: mockMoveGroupNodes,
});
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps({ groupId: "non-existent" })} />
</TestWrapper>
);
// Should render nothing
expect(container.querySelector(".rounded-xl")).not.toBeInTheDocument();
});
});
describe("Group Name Editing", () => {
it("should enter edit mode when name is clicked", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const nameSpan = screen.getByText("Test Group");
fireEvent.click(nameSpan);
// Input should be visible
const input = screen.getByDisplayValue("Test Group");
expect(input).toBeInTheDocument();
});
it("should update group name on Enter key", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Click to edit
const nameSpan = screen.getByText("Test Group");
fireEvent.click(nameSpan);
// Change value and press Enter
const input = screen.getByDisplayValue("Test Group");
fireEvent.change(input, { target: { value: "New Name" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { name: "New Name" });
});
it("should cancel editing on Escape key", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Click to edit
const nameSpan = screen.getByText("Test Group");
fireEvent.click(nameSpan);
// Change value and press Escape
const input = screen.getByDisplayValue("Test Group");
fireEvent.change(input, { target: { value: "Changed" } });
fireEvent.keyDown(input, { key: "Escape" });
// Should revert and exit edit mode
expect(mockUpdateGroup).not.toHaveBeenCalled();
expect(screen.getByText("Test Group")).toBeInTheDocument();
});
it("should update group name on blur", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Click to edit
const nameSpan = screen.getByText("Test Group");
fireEvent.click(nameSpan);
// Change value and blur
const input = screen.getByDisplayValue("Test Group");
fireEvent.change(input, { target: { value: "Blurred Name" } });
fireEvent.blur(input);
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { name: "Blurred Name" });
});
it("should not update if name is unchanged", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Click to edit
const nameSpan = screen.getByText("Test Group");
fireEvent.click(nameSpan);
// Press Enter without changing
const input = screen.getByDisplayValue("Test Group");
fireEvent.keyDown(input, { key: "Enter" });
expect(mockUpdateGroup).not.toHaveBeenCalled();
});
it("should not update if name is empty", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Click to edit
const nameSpan = screen.getByText("Test Group");
fireEvent.click(nameSpan);
// Clear value and blur
const input = screen.getByDisplayValue("Test Group");
fireEvent.change(input, { target: { value: " " } });
fireEvent.blur(input);
expect(mockUpdateGroup).not.toHaveBeenCalled();
});
});
describe("Color Picker", () => {
it("should open color picker when color button is clicked", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const colorButton = container.querySelector('button[title="Change color"]');
fireEvent.click(colorButton!);
// Color options should be visible (6 colors)
const colorOptions = container.querySelectorAll('.grid.grid-cols-4 button');
expect(colorOptions.length).toBe(6);
});
it("should change group color when color option is clicked", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Open color picker
const colorButton = container.querySelector('button[title="Change color"]');
fireEvent.click(colorButton!);
// Click on green color option
const greenOption = container.querySelector('button[title="Green"]');
fireEvent.click(greenOption!);
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", { color: "green" });
});
it("should close color picker after selecting a color", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Open color picker
const colorButton = container.querySelector('button[title="Change color"]');
fireEvent.click(colorButton!);
// Select a color
const greenOption = container.querySelector('button[title="Green"]');
fireEvent.click(greenOption!);
// Color picker should be closed
const colorOptions = container.querySelectorAll('.grid.grid-cols-4 button');
expect(colorOptions.length).toBe(0);
});
it("should highlight current color in picker", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Open color picker
const colorButton = container.querySelector('button[title="Change color"]');
fireEvent.click(colorButton!);
// Blue option should have highlight styles (current color)
const blueOption = container.querySelector('button[title="Blue"]');
expect(blueOption).toHaveClass("border-white");
});
});
describe("Delete Functionality", () => {
it("should call deleteGroup when delete button is clicked", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const deleteButton = container.querySelector('button[title="Delete group"]');
fireEvent.click(deleteButton!);
expect(mockDeleteGroup).toHaveBeenCalledWith("group-1");
});
});
describe("Node Resizer", () => {
it("should show resizer when selected", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} selected={true} />
</TestWrapper>
);
const resizer = screen.getByTestId("node-resizer");
expect(resizer).toHaveAttribute("data-visible", "true");
});
it("should hide resizer when not selected", () => {
render(
<TestWrapper>
<GroupNode {...createNodeProps()} selected={false} />
</TestWrapper>
);
const resizer = screen.getByTestId("node-resizer");
expect(resizer).toHaveAttribute("data-visible", "false");
});
});
describe("Header Drag", () => {
it("should have cursor-grab class on header", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const header = container.querySelector(".cursor-grab");
expect(header).toBeInTheDocument();
});
it("should not start drag when clicking on buttons", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
// Click on delete button
const deleteButton = container.querySelector('button[title="Delete group"]');
fireEvent.mouseDown(deleteButton!);
// moveGroupNodes should not be called
expect(mockMoveGroupNodes).not.toHaveBeenCalled();
});
it("should start drag when clicking on header background", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const header = container.querySelector(".cursor-grab");
fireEvent.mouseDown(header!, { clientX: 100, clientY: 100 });
// Move mouse
fireEvent.mouseMove(window, { clientX: 120, clientY: 120 });
// moveGroupNodes should be called with delta
expect(mockMoveGroupNodes).toHaveBeenCalledWith("group-1", { x: 20, y: 20 });
});
it("should stop drag on mouseup", () => {
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const header = container.querySelector(".cursor-grab");
fireEvent.mouseDown(header!, { clientX: 100, clientY: 100 });
fireEvent.mouseMove(window, { clientX: 120, clientY: 120 });
fireEvent.mouseUp(window);
// Clear the mock
mockMoveGroupNodes.mockClear();
// Move mouse again - should not trigger moveGroupNodes
fireEvent.mouseMove(window, { clientX: 140, clientY: 140 });
expect(mockMoveGroupNodes).not.toHaveBeenCalled();
});
});
describe("Different Group Colors", () => {
// Map color names to RGB values (browser converts hex to rgb)
it.each([
["neutral", "rgb(38, 38, 38)"],
["blue", "rgb(30, 58, 95)"],
["green", "rgb(26, 61, 46)"],
["purple", "rgb(45, 36, 88)"],
["orange", "rgb(61, 42, 26)"],
["red", "rgb(61, 26, 26)"],
])("should render with %s color", (colorName, colorRgb) => {
mockUseWorkflowStore.mockReturnValue({
groups: {
"group-1": { ...defaultGroup, color: colorName }
},
updateGroup: mockUpdateGroup,
deleteGroup: mockDeleteGroup,
moveGroupNodes: mockMoveGroupNodes,
});
const { container } = render(
<TestWrapper>
<GroupNode {...createNodeProps()} />
</TestWrapper>
);
const groupContainer = container.querySelector(".rounded-xl");
// Check that container has inline style with the color (browser converts hex to rgb)
expect(groupContainer?.getAttribute("style")).toContain(colorRgb);
});
});
});

397
src/components/__tests__/SplitGridNode.test.tsx

@ -0,0 +1,397 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { SplitGridNode } from "@/components/nodes/SplitGridNode";
import { ReactFlowProvider } from "@xyflow/react";
import { SplitGridNodeData } from "@/types";
// Mock the workflow store
const mockUpdateNodeData = vi.fn();
const mockRegenerateNode = vi.fn();
const mockUseWorkflowStore = vi.fn();
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector: (state: unknown) => unknown) => mockUseWorkflowStore(selector),
}));
// Mock useReactFlow
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual("@xyflow/react");
return {
...actual,
useReactFlow: () => ({
getNodes: vi.fn(() => []),
setNodes: vi.fn(),
}),
};
});
// Mock the SplitGridSettingsModal
vi.mock("@/components/SplitGridSettingsModal", () => ({
SplitGridSettingsModal: ({ onClose }: { onClose: () => void }) => (
<div data-testid="split-grid-settings-modal">
<button onClick={onClose}>Close Modal</button>
</div>
),
}));
// Wrapper component for React Flow context
function TestWrapper({ children }: { children: React.ReactNode }) {
return <ReactFlowProvider>{children}</ReactFlowProvider>;
}
describe("SplitGridNode", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation
mockUseWorkflowStore.mockImplementation((selector) => {
const state = {
updateNodeData: mockUpdateNodeData,
regenerateNode: mockRegenerateNode,
isRunning: false,
currentNodeId: null,
groups: {},
nodes: [],
};
return selector(state);
});
});
const createDefaultNodeData = (overrides: Partial<SplitGridNodeData> = {}): SplitGridNodeData => ({
sourceImage: null,
targetCount: 4,
defaultPrompt: "",
generateSettings: {
aspectRatio: "1:1",
resolution: "1K",
model: "nano-banana",
useGoogleSearch: false,
},
childNodeIds: [],
gridRows: 2,
gridCols: 2,
isConfigured: false,
status: "idle",
error: null,
...overrides,
});
const createNodeProps = (data: Partial<SplitGridNodeData> = {}) => ({
id: "split-grid-node-1",
type: "splitGrid" as const,
data: createDefaultNodeData(data),
selected: false,
});
describe("Basic Rendering", () => {
it("should render the title 'Split Grid'", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.getByText("Split Grid")).toBeInTheDocument();
});
it("should render input handle for image", () => {
const { container } = render(
<TestWrapper>
<SplitGridNode {...createNodeProps()} />
</TestWrapper>
);
const imageHandle = container.querySelector('[data-handletype="image"]');
expect(imageHandle).toBeInTheDocument();
});
it("should render output handle for reference", () => {
const { container } = render(
<TestWrapper>
<SplitGridNode {...createNodeProps()} />
</TestWrapper>
);
const referenceHandle = container.querySelector('[data-handletype="reference"]');
expect(referenceHandle).toBeInTheDocument();
});
it("should render grid configuration summary", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ gridRows: 2, gridCols: 3, targetCount: 6 })} />
</TestWrapper>
);
expect(screen.getByText("2x3 grid (6 images)")).toBeInTheDocument();
});
});
describe("Empty State", () => {
it("should show 'Connect image' message when no source image", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ sourceImage: null })} />
</TestWrapper>
);
expect(screen.getByText("Connect image")).toBeInTheDocument();
});
it("should show unconfigured warning when not configured", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ isConfigured: false })} />
</TestWrapper>
);
expect(screen.getByText("Not configured - click Settings")).toBeInTheDocument();
});
});
describe("Source Image Display", () => {
it("should display source image when provided", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ sourceImage: "data:image/png;base64,abc123" })} />
</TestWrapper>
);
const img = screen.getByAltText("Source grid");
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", "data:image/png;base64,abc123");
});
it("should show grid overlay on source image", () => {
const { container } = render(
<TestWrapper>
<SplitGridNode {...createNodeProps({
sourceImage: "data:image/png;base64,abc123",
gridRows: 2,
gridCols: 2,
targetCount: 4
})} />
</TestWrapper>
);
// Check for grid overlay cells
const gridCells = container.querySelectorAll(".border.border-blue-400\\/50");
expect(gridCells.length).toBe(4);
});
});
describe("Settings Modal", () => {
it("should open settings modal when Settings button is clicked", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps()} />
</TestWrapper>
);
const settingsButton = screen.getByText("Settings");
fireEvent.click(settingsButton);
expect(screen.getByTestId("split-grid-settings-modal")).toBeInTheDocument();
});
it("should close settings modal when onClose is called", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps()} />
</TestWrapper>
);
// Open modal
const settingsButton = screen.getByText("Settings");
fireEvent.click(settingsButton);
expect(screen.getByTestId("split-grid-settings-modal")).toBeInTheDocument();
// Close modal
const closeButton = screen.getByText("Close Modal");
fireEvent.click(closeButton);
expect(screen.queryByTestId("split-grid-settings-modal")).not.toBeInTheDocument();
});
it("should auto-open settings when not configured and no child nodes", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ isConfigured: false, childNodeIds: [] })} />
</TestWrapper>
);
// Modal should be open automatically
expect(screen.getByTestId("split-grid-settings-modal")).toBeInTheDocument();
});
it("should not auto-open settings when already configured", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ isConfigured: true })} />
</TestWrapper>
);
// Modal should not be open
expect(screen.queryByTestId("split-grid-settings-modal")).not.toBeInTheDocument();
});
});
describe("Split Button", () => {
it("should render Split button", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ isConfigured: true })} />
</TestWrapper>
);
expect(screen.getByText("Split")).toBeInTheDocument();
});
it("should call regenerateNode when Split button is clicked", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ isConfigured: true })} />
</TestWrapper>
);
const splitButton = screen.getByText("Split");
fireEvent.click(splitButton);
expect(mockRegenerateNode).toHaveBeenCalledWith("split-grid-node-1");
});
it("should disable Split button when not configured", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ isConfigured: false })} />
</TestWrapper>
);
const splitButton = screen.getByText("Split");
expect(splitButton).toBeDisabled();
});
it("should disable Split button when workflow is running", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
const state = {
updateNodeData: mockUpdateNodeData,
regenerateNode: mockRegenerateNode,
isRunning: true,
currentNodeId: null,
groups: {},
nodes: [],
};
return selector(state);
});
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ isConfigured: true })} />
</TestWrapper>
);
const splitButton = screen.getByText("Split");
expect(splitButton).toBeDisabled();
});
});
describe("Child Node Count", () => {
it("should display child node count when configured", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({
isConfigured: true,
childNodeIds: [
{ imageInput: "1", prompt: "2", nanoBanana: "3" },
{ imageInput: "4", prompt: "5", nanoBanana: "6" },
{ imageInput: "7", prompt: "8", nanoBanana: "9" },
]
})} />
</TestWrapper>
);
expect(screen.getByText("3 generate sets created")).toBeInTheDocument();
});
});
describe("Loading State", () => {
it("should show loading spinner when status is loading", () => {
const { container } = render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ status: "loading" })} />
</TestWrapper>
);
const spinner = container.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
});
it("should show loading overlay on source image when loading", () => {
const { container } = render(
<TestWrapper>
<SplitGridNode {...createNodeProps({
sourceImage: "data:image/png;base64,abc",
status: "loading"
})} />
</TestWrapper>
);
// Check for loading overlay
const overlay = container.querySelector(".bg-neutral-900\\/70");
expect(overlay).toBeInTheDocument();
});
});
describe("Error State", () => {
it("should show error message when status is error", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ status: "error", error: "Something went wrong" })} />
</TestWrapper>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("should show default error message when error is null", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ status: "error", error: null })} />
</TestWrapper>
);
expect(screen.getByText("Error")).toBeInTheDocument();
});
});
describe("Custom Title", () => {
it("should display custom title when provided", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps({ customTitle: "My Split" })} />
</TestWrapper>
);
expect(screen.getByText("My Split - Split Grid")).toBeInTheDocument();
});
it("should call updateNodeData when custom title is changed", () => {
render(
<TestWrapper>
<SplitGridNode {...createNodeProps()} />
</TestWrapper>
);
// Click on title to edit
const title = screen.getByText("Split Grid");
fireEvent.click(title);
// Type new title
const input = screen.getByPlaceholderText("Custom title...");
fireEvent.change(input, { target: { value: "New Title" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(mockUpdateNodeData).toHaveBeenCalledWith("split-grid-node-1", { customTitle: "New Title" });
});
});
});
Loading…
Cancel
Save