import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { GenerateVideoNode } from "@/components/nodes/GenerateVideoNode"; import { ReactFlowProvider } from "@xyflow/react"; import { GenerateVideoNodeData, 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); }, })); // 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 - Note: Gemini doesn't support video 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("GenerateVideoNode", () => { 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 = {}): GenerateVideoNodeData => ({ inputImages: [], inputPrompt: null, outputVideo: null, status: "idle", error: null, videoHistory: [], selectedVideoHistoryIndex: 0, ...overrides, }); const createNodeProps = (data: Partial = {}) => ({ id: "test-node-1", type: "generateVideo" as const, data: createNodeData(data), selected: false, }); describe("Basic Rendering", () => { it("should render with fal.ai provider badge by default", () => { const { container } = render( ); // fal.ai badge SVG should be present - viewBox="0 0 1855 1855" const falBadge = container.querySelector('svg[viewBox="0 0 1855 1855"]'); expect(falBadge).toBeInTheDocument(); }); it("should render 'Select model...' when no model is selected", () => { render( ); // Default title when no model selected expect(screen.getByText("Select model...")).toBeInTheDocument(); }); it("should render model name as title when selectedModel is set", () => { render( ); expect(screen.getByText("Kling Video")).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 video output handle", () => { const { container } = render( ); const outputHandle = container.querySelector('[data-handletype="video"]'); expect(outputHandle).toBeInTheDocument(); }); it("should render handle labels", () => { render( ); // Should have Image and Prompt labels for inputs, Video for output expect(screen.getByText("Image")).toBeInTheDocument(); expect(screen.getByText("Prompt")).toBeInTheDocument(); expect(screen.getByText("Video")).toBeInTheDocument(); }); }); describe("Provider Selection (No Gemini)", () => { it("should not include Gemini in enabled providers", () => { // The component should only show fal.ai and Replicate (when configured) // Gemini is excluded because it doesn't support video generation render( ); // Check that there's no Gemini badge visible const { container } = render( ); // Gemini badge would have viewBox="0 0 65 65" - should not be present const geminiBadge = container.querySelector('svg[viewBox="0 0 65 65"]'); expect(geminiBadge).not.toBeInTheDocument(); }); it("should show fal.ai as default provider", () => { const { container } = render( ); // fal.ai badge should be the default const falBadge = container.querySelector('svg[viewBox="0 0 1855 1855"]'); expect(falBadge).toBeInTheDocument(); }); it("should show Replicate badge when Replicate provider is selected", async () => { 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( ); await waitFor(() => { const replicateBadge = container.querySelector('svg[viewBox="0 0 1000 1000"]'); expect(replicateBadge).toBeInTheDocument(); }); }); }); 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 video", () => { 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 video const spinner = container.querySelector(".animate-spin"); expect(spinner).toBeInTheDocument(); // Should still show the video element const video = container.querySelector("video"); expect(video).toBeInTheDocument(); }); }); describe("Error State", () => { it("should show error message when status is error and no output", () => { render( ); expect(screen.getByText("Video generation failed")).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 Video Display", () => { it("should render video element when data.outputVideo exists", () => { const { container } = render( ); const video = container.querySelector("video"); expect(video).toBeInTheDocument(); expect(video).toHaveAttribute("src", "data:video/mp4;base64,abc123"); }); it("should render video with controls attribute", () => { const { container } = render( ); const video = container.querySelector("video"); expect(video).toHaveAttribute("controls"); }); it("should render video with autoplay attribute", () => { const { container } = render( ); const video = container.querySelector("video"); expect(video?.autoplay).toBe(true); }); it("should render video with loop attribute", () => { const { container } = render( ); const video = container.querySelector("video"); expect(video).toHaveAttribute("loop"); }); it("should render video with muted attribute", () => { const { container } = render( ); const video = container.querySelector("video"); expect(video?.muted).toBe(true); }); it("should render clear button when output video exists", () => { render( ); const clearButton = screen.getByTitle("Clear video"); expect(clearButton).toBeInTheDocument(); }); it("should call updateNodeData to clear video when clear button is clicked", () => { render( ); const clearButton = screen.getByTitle("Clear video"); fireEvent.click(clearButton); expect(mockUpdateNodeData).toHaveBeenCalledWith("test-node-1", { outputVideo: null, status: "idle", error: null, }); }); }); describe("Video History Carousel", () => { it("should not show carousel controls when history has only one item", () => { render( ); expect(screen.queryByTitle("Previous video")).not.toBeInTheDocument(); expect(screen.queryByTitle("Next video")).not.toBeInTheDocument(); }); it("should show carousel controls when history has multiple items", () => { render( ); expect(screen.getByTitle("Previous video")).toBeInTheDocument(); expect(screen.getByTitle("Next video")).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", () => { 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 await waitFor(() => { expect(screen.getByText("Browse Models")).toBeInTheDocument(); }); }); }); describe("Dynamic Input Handles", () => { 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 Video Generator/)).toBeInTheDocument(); }); it("should call updateNodeData when custom title is changed", () => { render( ); // Click on title to edit const title = screen.getByText("Kling Video"); 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 model is selected", async () => { render( ); // ModelParameters should attempt to load schema await waitFor(() => { expect(mockFetch).toHaveBeenCalled(); }); }); }); describe("Fetch Models on Mount", () => { 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 request video capabilities when fetching models", async () => { render( ); await waitFor(() => { const fetchCalls = mockFetch.mock.calls.filter(call => typeof call[0] === 'string' && call[0].includes('text-to-video') ); expect(fetchCalls.length).toBeGreaterThan(0); }); }); }); });