diff --git a/src/components/__tests__/GenerateImageNode.test.tsx b/src/components/__tests__/GenerateImageNode.test.tsx new file mode 100644 index 00000000..f899fe8f --- /dev/null +++ b/src/components/__tests__/GenerateImageNode.test.tsx @@ -0,0 +1,738 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { GenerateImageNode } from "@/components/nodes/GenerateImageNode"; +import { ReactFlowProvider } from "@xyflow/react"; +import { NanoBananaNodeData, ProviderSettings } from "@/types"; + +// Mock the workflow store +const mockUpdateNodeData = vi.fn(); +const mockRegenerateNode = vi.fn(); +const mockAddNode = vi.fn(); +const mockIncrementModalCount = vi.fn(); +const mockDecrementModalCount = vi.fn(); +const mockUseWorkflowStore = vi.fn(); + +vi.mock("@/store/workflowStore", () => ({ + useWorkflowStore: (selector?: (state: unknown) => unknown) => { + if (selector) { + return mockUseWorkflowStore(selector); + } + // When called without selector (destructuring pattern), return the full state object + return mockUseWorkflowStore((s: unknown) => s); + }, + saveNanoBananaDefaults: vi.fn(), +})); + +// Mock useReactFlow +const mockSetNodes = vi.fn(); +const mockScreenToFlowPosition = vi.fn((pos) => pos); + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + useReactFlow: () => ({ + getNodes: vi.fn(() => []), + setNodes: mockSetNodes, + screenToFlowPosition: mockScreenToFlowPosition, + }), + }; +}); + +// Mock Toast +vi.mock("@/components/Toast", () => ({ + useToast: { + getState: () => ({ + show: vi.fn(), + }), + }, +})); + +// Mock createPortal for ModelSearchDialog +vi.mock("react-dom", async () => { + const actual = await vi.importActual("react-dom"); + return { + ...actual, + createPortal: (node: React.ReactNode) => node, + }; +}); + +// Mock fetch +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 }, + }, +}; + +describe("GenerateImageNode", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ models: [], success: true }), + }); + + // Default mock implementation + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + updateNodeData: mockUpdateNodeData, + regenerateNode: mockRegenerateNode, + addNode: mockAddNode, + incrementModalCount: mockIncrementModalCount, + decrementModalCount: mockDecrementModalCount, + providerSettings: defaultProviderSettings, + generationsPath: "/test/generations", + isRunning: false, + currentNodeId: null, + groups: {}, + nodes: [], + }; + return selector(state); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createNodeData = (overrides: Partial = {}): NanoBananaNodeData => ({ + inputImages: [], + inputPrompt: null, + outputImage: null, + aspectRatio: "1:1", + resolution: "1K", + model: "nano-banana-pro", + useGoogleSearch: false, + status: "idle", + error: null, + imageHistory: [], + selectedHistoryIndex: 0, + ...overrides, + }); + + const createNodeProps = (data: Partial = {}) => ({ + id: "test-node-1", + type: "nanoBanana" as const, + data: createNodeData(data), + selected: false, + }); + + describe("Basic Rendering", () => { + it("should render with Gemini provider badge by default", () => { + const { container } = render( + + + + ); + + // Gemini badge SVG should be present (star shape) + const geminiBadge = container.querySelector('svg[viewBox="0 0 65 65"]'); + expect(geminiBadge).toBeInTheDocument(); + }); + + it("should render the model name as title", () => { + render( + + + + ); + + // The title should show the display name + expect(screen.getByText("Nano Banana Pro")).toBeInTheDocument(); + }); + + it("should render display name from selectedModel when provided", () => { + render( + + + + ); + + expect(screen.getByText("FLUX.1 Dev")).toBeInTheDocument(); + }); + + it("should render image and text input handles", () => { + const { container } = render( + + + + ); + + const imageHandle = container.querySelector('[data-handletype="image"][class*="target"]'); + const textHandle = container.querySelector('[data-handletype="text"]'); + expect(imageHandle).toBeInTheDocument(); + expect(textHandle).toBeInTheDocument(); + }); + + it("should render image output handle", () => { + const { container } = render( + + + + ); + + const outputHandle = container.querySelector('[data-handletype="image"][class*="source"]'); + expect(outputHandle).toBeInTheDocument(); + }); + + it("should render handle labels", () => { + render( + + + + ); + + // Should have Image and Prompt labels for inputs, Image for output + const imageLabels = screen.getAllByText("Image"); + const promptLabels = screen.getAllByText("Prompt"); + expect(imageLabels.length).toBeGreaterThanOrEqual(1); + expect(promptLabels.length).toBe(1); + }); + }); + + describe("Idle State", () => { + it("should show 'Run to generate' message when idle and no output", () => { + render( + + + + ); + + expect(screen.getByText("Run to generate")).toBeInTheDocument(); + }); + + it("should render a dashed border placeholder when no output image", () => { + const { container } = render( + + + + ); + + const placeholder = container.querySelector(".border-dashed"); + expect(placeholder).toBeInTheDocument(); + }); + }); + + describe("Loading State", () => { + it("should show loading spinner when status is loading and no output", () => { + const { container } = render( + + + + ); + + const spinner = container.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + }); + + it("should show loading overlay when status is loading with existing output", () => { + const { container } = render( + + + + ); + + // Should show the spinner overlay on top of the image + const spinner = container.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + + // Should still show the image + const img = screen.getByAltText("Generated"); + expect(img).toBeInTheDocument(); + }); + }); + + describe("Error State", () => { + it("should show error message when status is error and no output", () => { + render( + + + + ); + + expect(screen.getByText("API error occurred")).toBeInTheDocument(); + }); + + it("should show error overlay when status is error with existing output", () => { + render( + + + + ); + + expect(screen.getByText("Generation failed")).toBeInTheDocument(); + expect(screen.getByText("See toast for details")).toBeInTheDocument(); + }); + + it("should show 'Failed' when error message is null", () => { + render( + + + + ); + + expect(screen.getByText("Failed")).toBeInTheDocument(); + }); + }); + + describe("Output Image Display", () => { + it("should render output image when data.outputImage exists", () => { + render( + + + + ); + + const img = screen.getByAltText("Generated"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute("src", "data:image/png;base64,abc123"); + }); + + it("should render clear button when output image exists", () => { + render( + + + + ); + + const clearButton = screen.getByTitle("Clear image"); + expect(clearButton).toBeInTheDocument(); + }); + + it("should call updateNodeData to clear image when clear button is clicked", () => { + render( + + + + ); + + const clearButton = screen.getByTitle("Clear image"); + fireEvent.click(clearButton); + + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { + outputImage: null, + status: "idle", + error: null, + }); + }); + }); + + describe("Image History Carousel", () => { + it("should not show carousel controls when history has only one item", () => { + render( + + + + ); + + expect(screen.queryByTitle("Previous image")).not.toBeInTheDocument(); + expect(screen.queryByTitle("Next image")).not.toBeInTheDocument(); + }); + + it("should show carousel controls when history has multiple items", () => { + render( + + + + ); + + expect(screen.getByTitle("Previous image")).toBeInTheDocument(); + expect(screen.getByTitle("Next image")).toBeInTheDocument(); + }); + + it("should show current position in carousel", () => { + render( + + + + ); + + expect(screen.getByText("2 / 3")).toBeInTheDocument(); + }); + }); + + describe("Run Button", () => { + it("should render run button", () => { + render( + + + + ); + + expect(screen.getByTitle("Run this node")).toBeInTheDocument(); + }); + + it("should call regenerateNode when run button is clicked", () => { + render( + + + + ); + + const runButton = screen.getByTitle("Run this node"); + fireEvent.click(runButton); + + expect(mockRegenerateNode).toHaveBeenCalledWith("test-node-1"); + }); + + it("should disable run button when workflow is running", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + updateNodeData: mockUpdateNodeData, + regenerateNode: mockRegenerateNode, + addNode: mockAddNode, + incrementModalCount: mockIncrementModalCount, + decrementModalCount: mockDecrementModalCount, + providerSettings: defaultProviderSettings, + generationsPath: "/test/generations", + isRunning: true, + currentNodeId: null, + groups: {}, + nodes: [], + }; + return selector(state); + }); + + render( + + + + ); + + const runButton = screen.getByTitle("Run this node"); + expect(runButton).toBeDisabled(); + }); + }); + + describe("Browse Button", () => { + it("should render Browse button when external providers are available", () => { + render( + + + + ); + + expect(screen.getByText("Browse")).toBeInTheDocument(); + }); + + it("should open ModelSearchDialog when Browse button is clicked", async () => { + render( + + + + ); + + const browseButton = screen.getByText("Browse"); + fireEvent.click(browseButton); + + // ModelSearchDialog should open - look for the dialog title + await waitFor(() => { + expect(screen.getByText("Browse Models")).toBeInTheDocument(); + }); + }); + }); + + describe("Provider Badge Display", () => { + it("should show Gemini badge for Gemini provider", () => { + const { container } = render( + + + + ); + + // Gemini badge uses viewBox="0 0 65 65" + const geminiBadge = container.querySelector('svg[viewBox="0 0 65 65"]'); + expect(geminiBadge).toBeInTheDocument(); + }); + + it("should show fal.ai badge for fal provider", async () => { + const { container } = render( + + + + ); + + // Wait for effects to run + await waitFor(() => { + // fal.ai badge uses viewBox="0 0 1855 1855" + const falBadge = container.querySelector('svg[viewBox="0 0 1855 1855"]'); + expect(falBadge).toBeInTheDocument(); + }); + }); + + it("should show Replicate badge for Replicate provider", async () => { + // Enable Replicate provider + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + updateNodeData: mockUpdateNodeData, + regenerateNode: mockRegenerateNode, + addNode: mockAddNode, + incrementModalCount: mockIncrementModalCount, + decrementModalCount: mockDecrementModalCount, + providerSettings: { + providers: { + ...defaultProviderSettings.providers, + replicate: { id: "replicate", name: "Replicate", enabled: true, apiKey: "test-key" }, + }, + }, + generationsPath: "/test/generations", + isRunning: false, + currentNodeId: null, + groups: {}, + nodes: [], + }; + return selector(state); + }); + + const { container } = render( + + + + ); + + // Wait for effects to run + await waitFor(() => { + // Replicate badge uses viewBox="0 0 1000 1000" + const replicateBadge = container.querySelector('svg[viewBox="0 0 1000 1000"]'); + expect(replicateBadge).toBeInTheDocument(); + }); + }); + }); + + describe("Legacy Data Migration", () => { + it("should migrate legacy model field to selectedModel", async () => { + render( + + + + ); + + // The migration effect should call updateNodeData + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { + selectedModel: { + provider: "gemini", + modelId: "nano-banana", + displayName: "Nano Banana", + }, + }); + }); + }); + }); + + describe("Dynamic Input Handles (External Providers)", () => { + it("should render dynamic handles when inputSchema is provided", () => { + const { container } = render( + + + + ); + + // Should have handles rendered + const imageHandle = container.querySelector('[data-handletype="image"]'); + const textHandle = container.querySelector('[data-handletype="text"]'); + expect(imageHandle).toBeInTheDocument(); + expect(textHandle).toBeInTheDocument(); + }); + + it("should show placeholder handles when schema lacks image or text inputs", () => { + const { container } = render( + + + + ); + + // Should still have both image and text handles (image as placeholder) + const imageHandle = container.querySelector('[data-handletype="image"]'); + const textHandle = container.querySelector('[data-handletype="text"]'); + expect(imageHandle).toBeInTheDocument(); + expect(textHandle).toBeInTheDocument(); + }); + }); + + describe("Custom Title and Comment", () => { + it("should display custom title when provided", () => { + render( + + + + ); + + expect(screen.getByText(/My Generator/)).toBeInTheDocument(); + }); + + it("should call updateNodeData when custom title is changed", () => { + render( + + + + ); + + // Click on title to edit + const title = screen.getByText("Nano Banana Pro"); + fireEvent.click(title); + + // Type new title + const input = screen.getByPlaceholderText("Custom title..."); + fireEvent.change(input, { target: { value: "New Title" } }); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { customTitle: "New Title" }); + }); + }); + + describe("ModelParameters Component", () => { + it("should render ModelParameters when external provider model is selected", async () => { + render( + + + + ); + + // ModelParameters should attempt to load schema + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + }); + + it("should not render ModelParameters for Gemini provider", async () => { + mockFetch.mockClear(); + + render( + + + + ); + + // Give time for any effects to run + await new Promise(resolve => setTimeout(resolve, 100)); + + // ModelParameters component should not fetch for Gemini + const fetchCalls = mockFetch.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes('/api/models/') + ); + expect(fetchCalls.length).toBe(0); + }); + }); + + describe("Fetch Models on Provider Change", () => { + it("should fetch models when provider is fal", async () => { + render( + + + + ); + + await waitFor(() => { + const fetchCalls = mockFetch.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes('/api/models?') + ); + expect(fetchCalls.length).toBeGreaterThan(0); + }); + }); + + it("should not fetch models when provider is gemini", async () => { + mockFetch.mockClear(); + + render( + + + + ); + + // Give time for any effects to run + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should not fetch models for Gemini (those are hardcoded) + const fetchCalls = mockFetch.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes('/api/models?provider=gemini') + ); + expect(fetchCalls.length).toBe(0); + }); + }); +});