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();
+ });
+ });
+});