From 1cbabc5a8f78a2c93ec34bc3ce344a916b9b4434 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 13 Jan 2026 09:51:00 +1300 Subject: [PATCH] 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 --- src/components/__tests__/GroupNode.test.tsx | 468 ++++++++++++++++++ .../__tests__/SplitGridNode.test.tsx | 397 +++++++++++++++ 2 files changed, 865 insertions(+) create mode 100644 src/components/__tests__/GroupNode.test.tsx create mode 100644 src/components/__tests__/SplitGridNode.test.tsx diff --git a/src/components/__tests__/GroupNode.test.tsx b/src/components/__tests__/GroupNode.test.tsx new file mode 100644 index 00000000..df52be53 --- /dev/null +++ b/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 = { + 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 }) => ( +
+ {children} +
+ ), + }; +}); + +// Wrapper component for React Flow context +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +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( + + + + ); + + expect(screen.getByText("Test Group")).toBeInTheDocument(); + }); + + it("should apply group color to background", () => { + const { container } = render( + + + + ); + + 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( + + + + ); + + const colorButton = container.querySelector('button[title="Change color"]'); + expect(colorButton).toBeInTheDocument(); + }); + + it("should render delete button", () => { + const { container } = render( + + + + ); + + 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( + + + + ); + + // Should render nothing + expect(container.querySelector(".rounded-xl")).not.toBeInTheDocument(); + }); + }); + + describe("Group Name Editing", () => { + it("should enter edit mode when name is clicked", () => { + render( + + + + ); + + 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( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + const resizer = screen.getByTestId("node-resizer"); + expect(resizer).toHaveAttribute("data-visible", "true"); + }); + + it("should hide resizer when not selected", () => { + render( + + + + ); + + 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( + + + + ); + + const header = container.querySelector(".cursor-grab"); + expect(header).toBeInTheDocument(); + }); + + it("should not start drag when clicking on buttons", () => { + const { container } = render( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + 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( + + + + ); + + 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); + }); + }); +}); diff --git a/src/components/__tests__/SplitGridNode.test.tsx b/src/components/__tests__/SplitGridNode.test.tsx new file mode 100644 index 00000000..69932536 --- /dev/null +++ b/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 }) => ( +
+ +
+ ), +})); + +// Wrapper component for React Flow context +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +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 => ({ + 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 = {}) => ({ + 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( + + + + ); + + expect(screen.getByText("Split Grid")).toBeInTheDocument(); + }); + + it("should render input handle for image", () => { + const { container } = render( + + + + ); + + const imageHandle = container.querySelector('[data-handletype="image"]'); + expect(imageHandle).toBeInTheDocument(); + }); + + it("should render output handle for reference", () => { + const { container } = render( + + + + ); + + const referenceHandle = container.querySelector('[data-handletype="reference"]'); + expect(referenceHandle).toBeInTheDocument(); + }); + + it("should render grid configuration summary", () => { + render( + + + + ); + + expect(screen.getByText("2x3 grid (6 images)")).toBeInTheDocument(); + }); + }); + + describe("Empty State", () => { + it("should show 'Connect image' message when no source image", () => { + render( + + + + ); + + expect(screen.getByText("Connect image")).toBeInTheDocument(); + }); + + it("should show unconfigured warning when not configured", () => { + render( + + + + ); + + expect(screen.getByText("Not configured - click Settings")).toBeInTheDocument(); + }); + }); + + describe("Source Image Display", () => { + it("should display source image when provided", () => { + render( + + + + ); + + 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( + + + + ); + + // 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( + + + + ); + + 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( + + + + ); + + // 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( + + + + ); + + // Modal should be open automatically + expect(screen.getByTestId("split-grid-settings-modal")).toBeInTheDocument(); + }); + + it("should not auto-open settings when already configured", () => { + render( + + + + ); + + // Modal should not be open + expect(screen.queryByTestId("split-grid-settings-modal")).not.toBeInTheDocument(); + }); + }); + + describe("Split Button", () => { + it("should render Split button", () => { + render( + + + + ); + + expect(screen.getByText("Split")).toBeInTheDocument(); + }); + + it("should call regenerateNode when Split button is clicked", () => { + render( + + + + ); + + const splitButton = screen.getByText("Split"); + fireEvent.click(splitButton); + + expect(mockRegenerateNode).toHaveBeenCalledWith("split-grid-node-1"); + }); + + it("should disable Split button when not configured", () => { + render( + + + + ); + + 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( + + + + ); + + const splitButton = screen.getByText("Split"); + expect(splitButton).toBeDisabled(); + }); + }); + + describe("Child Node Count", () => { + it("should display child node count when configured", () => { + render( + + + + ); + + expect(screen.getByText("3 generate sets created")).toBeInTheDocument(); + }); + }); + + describe("Loading State", () => { + it("should show loading spinner when status is loading", () => { + const { container } = render( + + + + ); + + const spinner = container.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + }); + + it("should show loading overlay on source image when loading", () => { + const { container } = render( + + + + ); + + // 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( + + + + ); + + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("should show default error message when error is null", () => { + render( + + + + ); + + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + }); + + describe("Custom Title", () => { + it("should display custom title when provided", () => { + render( + + + + ); + + expect(screen.getByText("My Split - Split Grid")).toBeInTheDocument(); + }); + + it("should call updateNodeData when custom title is changed", () => { + render( + + + + ); + + // 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" }); + }); + }); +});