Browse Source
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 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 1315 additions and 0 deletions
@ -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 ? ( |
|||
<div data-testid="model-search-dialog" data-provider={initialProvider}> |
|||
Model Search Dialog |
|||
<button onClick={onClose}>Close</button> |
|||
</div> |
|||
) : 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 <ReactFlowProvider>{children}</ReactFlowProvider>; |
|||
} |
|||
|
|||
// 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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByText("Generate")).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
it("should render Run button", async () => { |
|||
render( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByText("Run")).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
it("should render edge style toggle button", async () => { |
|||
render( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.getByTitle("Browse Replicate models")).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
it("should open ModelSearchDialog with fal provider when fal.ai icon is clicked", async () => { |
|||
render( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
await waitFor(() => { |
|||
expect(screen.queryByTitle("Run options")).not.toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
|
|||
it("should open run menu when dropdown chevron is clicked", async () => { |
|||
render( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<FloatingActionBar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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"); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -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 <ReactFlowProvider>{children}</ReactFlowProvider>; |
|||
} |
|||
|
|||
// 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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTitle("Stack horizontally (H)")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render stack vertically button", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTitle("Stack vertically (V)")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render arrange as grid button", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTitle("Arrange as grid (G)")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render create group button when nodes are not in a group", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
expect(screen.getByTitle("Create group")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("should render download images button", () => { |
|||
render( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
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( |
|||
<TestWrapper> |
|||
<MultiSelectToolbar /> |
|||
</TestWrapper> |
|||
); |
|||
|
|||
const toolbar = container.firstChild as HTMLElement; |
|||
expect(toolbar).toBeInTheDocument(); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue