From 6d05a9f158c0862db42d2314f7b7020fc392863a Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 13 Jan 2026 10:09:59 +1300 Subject: [PATCH] test(17-05): add FloatingActionBar and MultiSelectToolbar tests FloatingActionBar tests: - Basic rendering of node type buttons - Node button click adds node with correct type - Node button drag sets dataTransfer - Generate combo button dropdown menu - Provider icon buttons visibility and click - Edge style toggle - Run/Stop button states and actions - Run menu dropdown options MultiSelectToolbar tests: - Visibility based on selection count - Stack horizontally button - Stack vertically button - Arrange as grid button - Create group / ungroup buttons - Download images button - Toolbar position calculation Co-Authored-By: Claude Opus 4.5 --- .../__tests__/FloatingActionBar.test.tsx | 777 ++++++++++++++++++ .../__tests__/MultiSelectToolbar.test.tsx | 538 ++++++++++++ 2 files changed, 1315 insertions(+) create mode 100644 src/components/__tests__/FloatingActionBar.test.tsx create mode 100644 src/components/__tests__/MultiSelectToolbar.test.tsx diff --git a/src/components/__tests__/FloatingActionBar.test.tsx b/src/components/__tests__/FloatingActionBar.test.tsx new file mode 100644 index 00000000..06882aaf --- /dev/null +++ b/src/components/__tests__/FloatingActionBar.test.tsx @@ -0,0 +1,777 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { FloatingActionBar } from "@/components/FloatingActionBar"; +import { ReactFlowProvider } from "@xyflow/react"; +import { ProviderSettings } from "@/types"; + +// Mock the workflow store +const mockAddNode = vi.fn(); +const mockExecuteWorkflow = vi.fn(); +const mockRegenerateNode = vi.fn(); +const mockStopWorkflow = vi.fn(); +const mockValidateWorkflow = vi.fn(); +const mockSetEdgeStyle = vi.fn(); +const mockSetModelSearchOpen = vi.fn(); +const mockUseWorkflowStore = vi.fn(); + +vi.mock("@/store/workflowStore", () => ({ + useWorkflowStore: (selector?: (state: unknown) => unknown) => { + if (selector) { + return mockUseWorkflowStore(selector); + } + return mockUseWorkflowStore((s: unknown) => s); + }, +})); + +// Mock useReactFlow +const mockScreenToFlowPosition = vi.fn((pos) => pos); +const mockGetNodes = vi.fn(() => []); + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + useReactFlow: () => ({ + screenToFlowPosition: mockScreenToFlowPosition, + getNodes: mockGetNodes, + }), + }; +}); + +// Mock ModelSearchDialog +vi.mock("@/components/modals/ModelSearchDialog", () => ({ + ModelSearchDialog: ({ isOpen, onClose, initialProvider }: { isOpen: boolean; onClose: () => void; initialProvider?: string }) => ( + isOpen ? ( +
+ Model Search Dialog + +
+ ) : null + ), +})); + +// Mock fetch for env-status +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Wrapper component for React Flow context +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +// Default provider settings +const defaultProviderSettings: ProviderSettings = { + providers: { + gemini: { id: "gemini", name: "Gemini", enabled: true, apiKey: null, apiKeyEnvVar: "GEMINI_API_KEY" }, + openai: { id: "openai", name: "OpenAI", enabled: false, apiKey: null }, + replicate: { id: "replicate", name: "Replicate", enabled: false, apiKey: null }, + fal: { id: "fal", name: "fal.ai", enabled: true, apiKey: null }, + }, +}; + +// Default store state factory +const createDefaultState = (overrides = {}) => ({ + nodes: [], + isRunning: false, + executeWorkflow: mockExecuteWorkflow, + regenerateNode: mockRegenerateNode, + stopWorkflow: mockStopWorkflow, + validateWorkflow: mockValidateWorkflow, + edgeStyle: "angular" as const, + setEdgeStyle: mockSetEdgeStyle, + providerSettings: defaultProviderSettings, + setModelSearchOpen: mockSetModelSearchOpen, + modelSearchOpen: false, + modelSearchProvider: null, + addNode: mockAddNode, + ...overrides, +}); + +describe("FloatingActionBar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockValidateWorkflow.mockReturnValue({ valid: true, errors: [] }); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ gemini: true, openai: false, replicate: false }), + }); + + // Default mock implementation + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState()); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Basic Rendering", () => { + it("should render node type buttons", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Image")).toBeInTheDocument(); + expect(screen.getByText("Annotate")).toBeInTheDocument(); + expect(screen.getByText("Prompt")).toBeInTheDocument(); + expect(screen.getByText("Output")).toBeInTheDocument(); + }); + }); + + it("should render Generate combo button", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Generate")).toBeInTheDocument(); + }); + }); + + it("should render Run button", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Run")).toBeInTheDocument(); + }); + }); + + it("should render edge style toggle button", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Switch to curved connectors")).toBeInTheDocument(); + }); + }); + }); + + describe("Node Button Click", () => { + it("should call addNode when Image button is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Image")).toBeInTheDocument(); + }); + + const imageButton = screen.getByText("Image"); + fireEvent.click(imageButton); + + expect(mockAddNode).toHaveBeenCalledWith("imageInput", expect.any(Object)); + }); + + it("should call addNode when Annotate button is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Annotate")).toBeInTheDocument(); + }); + + const annotateButton = screen.getByText("Annotate"); + fireEvent.click(annotateButton); + + expect(mockAddNode).toHaveBeenCalledWith("annotation", expect.any(Object)); + }); + + it("should call addNode when Prompt button is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Prompt")).toBeInTheDocument(); + }); + + const promptButton = screen.getByText("Prompt"); + fireEvent.click(promptButton); + + expect(mockAddNode).toHaveBeenCalledWith("prompt", expect.any(Object)); + }); + + it("should call addNode when Output button is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Output")).toBeInTheDocument(); + }); + + const outputButton = screen.getByText("Output"); + fireEvent.click(outputButton); + + expect(mockAddNode).toHaveBeenCalledWith("output", expect.any(Object)); + }); + }); + + describe("Node Button Drag", () => { + it("should set dataTransfer with node type on drag start", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Image")).toBeInTheDocument(); + }); + + const imageButton = screen.getByText("Image"); + + const mockDataTransfer = { + setData: vi.fn(), + effectAllowed: "", + }; + + fireEvent.dragStart(imageButton, { + dataTransfer: mockDataTransfer, + }); + + expect(mockDataTransfer.setData).toHaveBeenCalledWith("application/node-type", "imageInput"); + expect(mockDataTransfer.effectAllowed).toBe("copy"); + }); + + it("should set dataTransfer with prompt type on drag", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Prompt")).toBeInTheDocument(); + }); + + const promptButton = screen.getByText("Prompt"); + + const mockDataTransfer = { + setData: vi.fn(), + effectAllowed: "", + }; + + fireEvent.dragStart(promptButton, { + dataTransfer: mockDataTransfer, + }); + + expect(mockDataTransfer.setData).toHaveBeenCalledWith("application/node-type", "prompt"); + }); + }); + + describe("Generate Combo Button", () => { + it("should open dropdown menu when Generate button is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Generate")).toBeInTheDocument(); + }); + + const generateButton = screen.getByText("Generate"); + fireEvent.click(generateButton); + + // Dropdown menu items should appear + expect(screen.getByText("Image", { selector: "button.w-full" })).toBeInTheDocument(); + expect(screen.getByText("Video")).toBeInTheDocument(); + expect(screen.getByText("Text (LLM)")).toBeInTheDocument(); + }); + + it("should add nanoBanana node when Image option is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Generate")).toBeInTheDocument(); + }); + + // Open dropdown + fireEvent.click(screen.getByText("Generate")); + + // Click Image option in dropdown + const imageOption = screen.getByText("Image", { selector: "button.w-full" }); + fireEvent.click(imageOption); + + expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", expect.any(Object)); + }); + + it("should add generateVideo node when Video option is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Generate")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Generate")); + fireEvent.click(screen.getByText("Video")); + + expect(mockAddNode).toHaveBeenCalledWith("generateVideo", expect.any(Object)); + }); + + it("should add llmGenerate node when Text (LLM) option is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Generate")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Generate")); + fireEvent.click(screen.getByText("Text (LLM)")); + + expect(mockAddNode).toHaveBeenCalledWith("llmGenerate", expect.any(Object)); + }); + + it("should close dropdown after selecting an option", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Generate")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Generate")); + + // Verify dropdown is open + expect(screen.getByText("Video")).toBeInTheDocument(); + + // Click an option + fireEvent.click(screen.getByText("Video")); + + // Dropdown should close + expect(screen.queryByText("Video")).not.toBeInTheDocument(); + }); + }); + + describe("Provider Icon Buttons", () => { + it("should render fal.ai icon button (always visible)", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Browse fal.ai models")).toBeInTheDocument(); + }); + }); + + it("should render Replicate icon when API key is configured", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + providerSettings: { + providers: { + ...defaultProviderSettings.providers, + replicate: { id: "replicate", name: "Replicate", enabled: true, apiKey: "test-key" }, + }, + }, + })); + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ gemini: true, openai: false, replicate: true }), + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Browse Replicate models")).toBeInTheDocument(); + }); + }); + + it("should open ModelSearchDialog with fal provider when fal.ai icon is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Browse fal.ai models")).toBeInTheDocument(); + }); + + const falButton = screen.getByTitle("Browse fal.ai models"); + fireEvent.click(falButton); + + expect(mockSetModelSearchOpen).toHaveBeenCalledWith(true, "fal"); + }); + + it("should open ModelSearchDialog with replicate provider when Replicate icon is clicked", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + providerSettings: { + providers: { + ...defaultProviderSettings.providers, + replicate: { id: "replicate", name: "Replicate", enabled: true, apiKey: "test-key" }, + }, + }, + })); + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ gemini: true, openai: false, replicate: true }), + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Browse Replicate models")).toBeInTheDocument(); + }); + + const replicateButton = screen.getByTitle("Browse Replicate models"); + fireEvent.click(replicateButton); + + expect(mockSetModelSearchOpen).toHaveBeenCalledWith(true, "replicate"); + }); + }); + + describe("Edge Style Toggle", () => { + it("should call setEdgeStyle with curved when currently angular", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Switch to curved connectors")).toBeInTheDocument(); + }); + + const toggleButton = screen.getByTitle("Switch to curved connectors"); + fireEvent.click(toggleButton); + + expect(mockSetEdgeStyle).toHaveBeenCalledWith("curved"); + }); + + it("should call setEdgeStyle with angular when currently curved", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + edgeStyle: "curved", + })); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Switch to angular connectors")).toBeInTheDocument(); + }); + + const toggleButton = screen.getByTitle("Switch to angular connectors"); + fireEvent.click(toggleButton); + + expect(mockSetEdgeStyle).toHaveBeenCalledWith("angular"); + }); + }); + + describe("Run Button", () => { + it("should call executeWorkflow when Run button is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Run")).toBeInTheDocument(); + }); + + const runButton = screen.getByText("Run"); + fireEvent.click(runButton); + + expect(mockExecuteWorkflow).toHaveBeenCalled(); + }); + + it("should show Stop button when isRunning is true", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + isRunning: true, + })); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Stop")).toBeInTheDocument(); + }); + }); + + it("should call stopWorkflow when Stop button is clicked", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + isRunning: true, + })); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Stop")).toBeInTheDocument(); + }); + + const stopButton = screen.getByText("Stop"); + fireEvent.click(stopButton); + + expect(mockStopWorkflow).toHaveBeenCalled(); + }); + + it("should disable Run button when workflow is invalid", async () => { + mockValidateWorkflow.mockReturnValue({ valid: false, errors: ["No nodes"] }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText("Run")).toBeInTheDocument(); + }); + + const runButton = screen.getByText("Run").closest("button"); + expect(runButton).toBeDisabled(); + }); + + it("should show error message in title when workflow is invalid", async () => { + mockValidateWorkflow.mockReturnValue({ valid: false, errors: ["Missing required nodes"] }); + + render( + + + + ); + + await waitFor(() => { + const runButton = screen.getByText("Run").closest("button"); + expect(runButton).toHaveAttribute("title", "Missing required nodes"); + }); + }); + }); + + describe("Run Menu Dropdown", () => { + it("should show dropdown chevron when workflow is valid and not running", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Run options")).toBeInTheDocument(); + }); + }); + + it("should not show dropdown chevron when workflow is invalid", async () => { + mockValidateWorkflow.mockReturnValue({ valid: false, errors: ["No nodes"] }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByTitle("Run options")).not.toBeInTheDocument(); + }); + }); + + it("should not show dropdown chevron when running", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + isRunning: true, + })); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByTitle("Run options")).not.toBeInTheDocument(); + }); + }); + + it("should open run menu when dropdown chevron is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Run options")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("Run options")); + + expect(screen.getByText("Run entire workflow")).toBeInTheDocument(); + expect(screen.getByText("Run from selected node")).toBeInTheDocument(); + expect(screen.getByText("Run selected node only")).toBeInTheDocument(); + }); + + it("should call executeWorkflow when 'Run entire workflow' is clicked", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Run options")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("Run options")); + fireEvent.click(screen.getByText("Run entire workflow")); + + expect(mockExecuteWorkflow).toHaveBeenCalled(); + }); + + it("should disable 'Run from selected node' when no node is selected", async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Run options")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("Run options")); + + const runFromSelectedButton = screen.getByText("Run from selected node").closest("button"); + expect(runFromSelectedButton).toHaveClass("cursor-not-allowed"); + }); + + it("should enable 'Run from selected node' when a single node is selected", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [{ id: "node-1", selected: true, type: "prompt" }], + })); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Run options")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("Run options")); + + const runFromSelectedButton = screen.getByText("Run from selected node").closest("button"); + expect(runFromSelectedButton).not.toHaveClass("cursor-not-allowed"); + }); + + it("should call executeWorkflow with node id when 'Run from selected node' is clicked", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [{ id: "node-1", selected: true, type: "prompt" }], + })); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Run options")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("Run options")); + fireEvent.click(screen.getByText("Run from selected node")); + + expect(mockExecuteWorkflow).toHaveBeenCalledWith("node-1"); + }); + + it("should call regenerateNode when 'Run selected node only' is clicked", async () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [{ id: "node-1", selected: true, type: "prompt" }], + })); + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTitle("Run options")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle("Run options")); + fireEvent.click(screen.getByText("Run selected node only")); + + expect(mockRegenerateNode).toHaveBeenCalledWith("node-1"); + }); + }); +}); diff --git a/src/components/__tests__/MultiSelectToolbar.test.tsx b/src/components/__tests__/MultiSelectToolbar.test.tsx new file mode 100644 index 00000000..f14273c0 --- /dev/null +++ b/src/components/__tests__/MultiSelectToolbar.test.tsx @@ -0,0 +1,538 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MultiSelectToolbar } from "@/components/MultiSelectToolbar"; +import { ReactFlowProvider } from "@xyflow/react"; + +// Mock JSZip +vi.mock("jszip", () => ({ + default: vi.fn().mockImplementation(() => ({ + file: vi.fn(), + generateAsync: vi.fn().mockResolvedValue(new Blob()), + })), +})); + +// Mock the workflow store +const mockOnNodesChange = vi.fn(); +const mockCreateGroup = vi.fn(); +const mockRemoveNodesFromGroup = vi.fn(); +const mockUseWorkflowStore = vi.fn(); + +vi.mock("@/store/workflowStore", () => ({ + useWorkflowStore: (selector?: (state: unknown) => unknown) => { + if (selector) { + return mockUseWorkflowStore(selector); + } + return mockUseWorkflowStore((s: unknown) => s); + }, +})); + +// Mock useReactFlow +const mockGetViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 })); + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + useReactFlow: () => ({ + getViewport: mockGetViewport, + }), + }; +}); + +// Wrapper component for React Flow context +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +// Create mock nodes for testing +const createMockNode = (id: string, overrides = {}) => ({ + id, + type: "prompt", + position: { x: 100, y: 100 }, + data: {}, + selected: true, + measured: { width: 220, height: 200 }, + ...overrides, +}); + +// Default store state factory +const createDefaultState = (overrides = {}) => ({ + nodes: [], + onNodesChange: mockOnNodesChange, + createGroup: mockCreateGroup, + removeNodesFromGroup: mockRemoveNodesFromGroup, + ...overrides, +}); + +describe("MultiSelectToolbar", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default mock implementation - no nodes selected + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState()); + }); + }); + + describe("Visibility", () => { + it("should not render when no nodes are selected", () => { + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should not render when only one node is selected", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [createMockNode("node-1")], + })); + }); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + + it("should render when two or more nodes are selected", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 } }), + createMockNode("node-2", { position: { x: 300, y: 0 } }), + ], + })); + }); + + render( + + + + ); + + expect(screen.getByTitle("Stack horizontally (H)")).toBeInTheDocument(); + }); + + it("should not render when nodes are selected but less than 2", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1"), + createMockNode("node-2", { selected: false }), + ], + })); + }); + + const { container } = render( + + + + ); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe("Basic Rendering", () => { + beforeEach(() => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 } }), + createMockNode("node-2", { position: { x: 300, y: 0 } }), + ], + })); + }); + }); + + it("should render stack horizontally button", () => { + render( + + + + ); + + expect(screen.getByTitle("Stack horizontally (H)")).toBeInTheDocument(); + }); + + it("should render stack vertically button", () => { + render( + + + + ); + + expect(screen.getByTitle("Stack vertically (V)")).toBeInTheDocument(); + }); + + it("should render arrange as grid button", () => { + render( + + + + ); + + expect(screen.getByTitle("Arrange as grid (G)")).toBeInTheDocument(); + }); + + it("should render create group button when nodes are not in a group", () => { + render( + + + + ); + + expect(screen.getByTitle("Create group")).toBeInTheDocument(); + }); + + it("should render download images button", () => { + render( + + + + ); + + expect(screen.getByTitle("Download images as ZIP")).toBeInTheDocument(); + }); + }); + + describe("Stack Horizontally", () => { + it("should call onNodesChange when stack horizontally button is clicked", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 50 } }), + createMockNode("node-2", { position: { x: 300, y: 0 } }), + ], + })); + }); + + render( + + + + ); + + const stackHorizontalButton = screen.getByTitle("Stack horizontally (H)"); + fireEvent.click(stackHorizontalButton); + + // Should be called for each node + expect(mockOnNodesChange).toHaveBeenCalled(); + }); + + it("should position nodes from left to right based on their original x position", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 500, y: 100 } }), + createMockNode("node-2", { position: { x: 100, y: 50 } }), + ], + })); + }); + + render( + + + + ); + + fireEvent.click(screen.getByTitle("Stack horizontally (H)")); + + // First call should position node-2 (at x=100, so it comes first) + expect(mockOnNodesChange).toHaveBeenCalledWith([ + expect.objectContaining({ + type: "position", + id: "node-2", + position: expect.objectContaining({ y: 50 }), // Aligned to topmost y + }), + ]); + }); + }); + + describe("Stack Vertically", () => { + it("should call onNodesChange when stack vertically button is clicked", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 50, y: 0 } }), + createMockNode("node-2", { position: { x: 0, y: 300 } }), + ], + })); + }); + + render( + + + + ); + + const stackVerticalButton = screen.getByTitle("Stack vertically (V)"); + fireEvent.click(stackVerticalButton); + + expect(mockOnNodesChange).toHaveBeenCalled(); + }); + + it("should position nodes from top to bottom based on their original y position", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 100, y: 500 } }), + createMockNode("node-2", { position: { x: 50, y: 100 } }), + ], + })); + }); + + render( + + + + ); + + fireEvent.click(screen.getByTitle("Stack vertically (V)")); + + // First call should position node-2 (at y=100, so it comes first) + expect(mockOnNodesChange).toHaveBeenCalledWith([ + expect.objectContaining({ + type: "position", + id: "node-2", + position: expect.objectContaining({ x: 50 }), // Aligned to leftmost x + }), + ]); + }); + }); + + describe("Arrange as Grid", () => { + it("should call onNodesChange when arrange as grid button is clicked", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 } }), + createMockNode("node-2", { position: { x: 100, y: 100 } }), + createMockNode("node-3", { position: { x: 200, y: 200 } }), + createMockNode("node-4", { position: { x: 300, y: 300 } }), + ], + })); + }); + + render( + + + + ); + + const gridButton = screen.getByTitle("Arrange as grid (G)"); + fireEvent.click(gridButton); + + expect(mockOnNodesChange).toHaveBeenCalled(); + }); + + it("should arrange nodes in a grid pattern", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 } }), + createMockNode("node-2", { position: { x: 500, y: 0 } }), + createMockNode("node-3", { position: { x: 0, y: 500 } }), + createMockNode("node-4", { position: { x: 500, y: 500 } }), + ], + })); + }); + + render( + + + + ); + + fireEvent.click(screen.getByTitle("Arrange as grid (G)")); + + // With 4 nodes, should create a 2x2 grid + expect(mockOnNodesChange).toHaveBeenCalled(); + }); + }); + + describe("Create Group", () => { + it("should call createGroup with selected node IDs when create group button is clicked", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 } }), + createMockNode("node-2", { position: { x: 300, y: 0 } }), + ], + })); + }); + + render( + + + + ); + + const createGroupButton = screen.getByTitle("Create group"); + fireEvent.click(createGroupButton); + + expect(mockCreateGroup).toHaveBeenCalledWith(["node-1", "node-2"]); + }); + + it("should show ungroup button when selected nodes are in a group", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 }, groupId: "group-1" }), + createMockNode("node-2", { position: { x: 300, y: 0 }, groupId: "group-1" }), + ], + })); + }); + + render( + + + + ); + + expect(screen.getByTitle("Remove from group")).toBeInTheDocument(); + expect(screen.queryByTitle("Create group")).not.toBeInTheDocument(); + }); + + it("should call removeNodesFromGroup when ungroup button is clicked", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 }, groupId: "group-1" }), + createMockNode("node-2", { position: { x: 300, y: 0 }, groupId: "group-1" }), + ], + })); + }); + + render( + + + + ); + + const ungroupButton = screen.getByTitle("Remove from group"); + fireEvent.click(ungroupButton); + + expect(mockRemoveNodesFromGroup).toHaveBeenCalledWith(["node-1", "node-2"]); + }); + + it("should show ungroup button when at least one selected node is in a group", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 }, groupId: "group-1" }), + createMockNode("node-2", { position: { x: 300, y: 0 } }), // Not in a group + ], + })); + }); + + render( + + + + ); + + expect(screen.getByTitle("Remove from group")).toBeInTheDocument(); + }); + }); + + describe("Download Images", () => { + it("should render download images button", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 } }), + createMockNode("node-2", { position: { x: 300, y: 0 } }), + ], + })); + }); + + render( + + + + ); + + expect(screen.getByTitle("Download images as ZIP")).toBeInTheDocument(); + }); + + it("should not download when no images are available", async () => { + // Mock URL.createObjectURL and URL.revokeObjectURL + const createObjectURLSpy = vi.fn(); + const revokeObjectURLSpy = vi.fn(); + global.URL.createObjectURL = createObjectURLSpy; + global.URL.revokeObjectURL = revokeObjectURLSpy; + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { type: "prompt", position: { x: 0, y: 0 }, data: { prompt: "test" } }), + createMockNode("node-2", { type: "prompt", position: { x: 300, y: 0 }, data: { prompt: "test2" } }), + ], + })); + }); + + render( + + + + ); + + const downloadButton = screen.getByTitle("Download images as ZIP"); + fireEvent.click(downloadButton); + + // Should not create object URL when no images + expect(createObjectURLSpy).not.toHaveBeenCalled(); + }); + }); + + describe("Toolbar Position", () => { + it("should position toolbar based on selected nodes bounding box", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 100, y: 200 } }), + createMockNode("node-2", { position: { x: 400, y: 200 } }), + ], + })); + }); + + const { container } = render( + + + + ); + + const toolbar = container.firstChild as HTMLElement; + expect(toolbar).toHaveStyle({ transform: "translateX(-50%)" }); + }); + + it("should account for viewport zoom in positioning", () => { + mockGetViewport.mockReturnValue({ x: 100, y: 50, zoom: 2 }); + + mockUseWorkflowStore.mockImplementation((selector) => { + return selector(createDefaultState({ + nodes: [ + createMockNode("node-1", { position: { x: 0, y: 0 } }), + createMockNode("node-2", { position: { x: 200, y: 0 } }), + ], + })); + }); + + const { container } = render( + + + + ); + + const toolbar = container.firstChild as HTMLElement; + expect(toolbar).toBeInTheDocument(); + }); + }); +});