From be43bbc6d89569bd685be65fef043c6de35bdaa6 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 13 Jan 2026 09:58:32 +1300 Subject: [PATCH] test(17-03): add GenerateVideoNode component tests - Add comprehensive tests for GenerateVideoNode component - Test fal.ai and Replicate provider badges (Gemini excluded) - Verify Gemini is not available for video generation - Test video output display with controls, autoplay, loop, muted - Test video history carousel navigation - Test loading and error states - Test dynamic input handles from schema - Test run button and custom title functionality - Test Browse button opens ModelSearchDialog Co-Authored-By: Claude Opus 4.5 --- .../__tests__/GenerateVideoNode.test.tsx | 738 ++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 src/components/__tests__/GenerateVideoNode.test.tsx diff --git a/src/components/__tests__/GenerateVideoNode.test.tsx b/src/components/__tests__/GenerateVideoNode.test.tsx new file mode 100644 index 00000000..3845ed64 --- /dev/null +++ b/src/components/__tests__/GenerateVideoNode.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 { 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); + }); + }); + }); +});