From 18bf0a7e83b6ea161aa27fc7abd6dda0e344b2fc Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 18 Feb 2026 08:57:12 +1300 Subject: [PATCH] test: add GenerateAudioNode component tests Add comprehensive tests covering rendering, provider selection, loading/error states, audio output controls, regenerate button, audio history carousel, browse dialog, model fetching, and dynamic input handles. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/GenerateAudioNode.test.tsx | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 src/components/__tests__/GenerateAudioNode.test.tsx diff --git a/src/components/__tests__/GenerateAudioNode.test.tsx b/src/components/__tests__/GenerateAudioNode.test.tsx new file mode 100644 index 00000000..c4b801ca --- /dev/null +++ b/src/components/__tests__/GenerateAudioNode.test.tsx @@ -0,0 +1,516 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { GenerateAudioNode } from "@/components/nodes/GenerateAudioNode"; +import { ReactFlowProvider } from "@xyflow/react"; +import { GenerateAudioNodeData, ProviderSettings } from "@/types"; + +// Mock deduplicatedFetch to pass through to global fetch (avoids caching issues in tests) +vi.mock("@/utils/deduplicatedFetch", () => ({ + deduplicatedFetch: (...args: Parameters) => fetch(...args), + clearFetchCache: vi.fn(), +})); + +// Mock the workflow store +const mockUpdateNodeData = vi.fn(); +const mockRegenerateNode = 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); + } + return mockUseWorkflowStore((s: unknown) => s); + }, + useProviderApiKeys: () => ({ + replicateApiKey: null, + falApiKey: null, + kieApiKey: null, + wavespeedApiKey: null, + replicateEnabled: false, + kieEnabled: false, + }), +})); + +// Mock useReactFlow +const mockSetNodes = vi.fn(); + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + return { + ...actual, + useReactFlow: () => ({ + getNodes: vi.fn(() => []), + setNodes: mockSetNodes, + screenToFlowPosition: vi.fn((pos) => pos), + }), + }; +}); + +// 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 useAudioVisualization +vi.mock("@/hooks/useAudioVisualization", () => ({ + useAudioVisualization: () => ({ + waveformData: null, + isLoading: false, + }), +})); + +// 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 }, + kie: { id: "kie", name: "Kie.ai", enabled: false, apiKey: null }, + wavespeed: { id: "wavespeed", name: "WaveSpeed", enabled: false, apiKey: null }, + }, +}; + +describe("GenerateAudioNode", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ models: [], success: true }), + }); + + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + updateNodeData: mockUpdateNodeData, + regenerateNode: mockRegenerateNode, + incrementModalCount: mockIncrementModalCount, + decrementModalCount: mockDecrementModalCount, + providerSettings: defaultProviderSettings, + generationsPath: "/test/generations", + isRunning: false, + currentNodeIds: [], + groups: {}, + nodes: [], + recentModels: [], + trackModelUsage: vi.fn(), + getNodesWithComments: vi.fn(() => []), + markCommentViewed: vi.fn(), + setNavigationTarget: vi.fn(), + }; + return selector(state); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createNodeData = (overrides: Partial = {}): GenerateAudioNodeData => ({ + inputPrompt: null, + outputAudio: null, + status: "idle", + error: null, + audioHistory: [], + selectedAudioHistoryIndex: 0, + duration: null, + format: null, + ...overrides, + }); + + const createNodeProps = (data: Partial = {}) => ({ + id: "test-audio-1", + type: "generateAudio" as const, + data: createNodeData(data), + selected: false, + }); + + describe("Basic Rendering", () => { + it("should render with default title when no model selected", () => { + render( + + + + ); + + expect(screen.getByText("Generate Audio")).toBeInTheDocument(); + }); + + it("should render model name as title when selectedModel is set", () => { + render( + + + + ); + + expect(screen.getByText("ElevenLabs Turbo v2.5")).toBeInTheDocument(); + }); + + it("should render text input handle", () => { + const { container } = render( + + + + ); + + const textHandle = container.querySelector('[data-handletype="text"]'); + expect(textHandle).toBeInTheDocument(); + }); + + it("should render audio output handle", () => { + const { container } = render( + + + + ); + + const outputHandle = container.querySelector('[data-handletype="audio"]'); + expect(outputHandle).toBeInTheDocument(); + }); + }); + + describe("Provider Selection", () => { + it("should show fal.ai as default provider", () => { + const { container } = render( + + + + ); + + // fal.ai badge should be the default (check via select value) + const select = container.querySelector('select') as HTMLSelectElement; + expect(select).toBeInTheDocument(); + expect(select.value).toBe("fal"); + }); + + it("should update provider on change", () => { + render( + + + + ); + + // Provider select should be present + const selects = screen.getAllByRole("combobox"); + expect(selects.length).toBeGreaterThan(0); + }); + }); + + describe("Loading State", () => { + it("should show loading spinner when status is loading", () => { + const { container } = render( + + + + ); + + const spinner = container.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + expect(screen.getByText("Generating audio...")).toBeInTheDocument(); + }); + }); + + describe("Error State", () => { + it("should show error message when status is error", () => { + render( + + + + ); + + expect(screen.getByText("Audio generation failed")).toBeInTheDocument(); + }); + }); + + describe("Audio Output", () => { + it("should render play button when audio is present", () => { + render( + + + + ); + + const playButton = screen.getByTitle("Play"); + expect(playButton).toBeInTheDocument(); + }); + + it("should render clear button when audio is present", () => { + render( + + + + ); + + const clearButton = screen.getByTitle("Clear audio"); + expect(clearButton).toBeInTheDocument(); + }); + + it("should call updateNodeData to clear audio when clear button is clicked", () => { + render( + + + + ); + + const clearButton = screen.getByTitle("Clear audio"); + fireEvent.click(clearButton); + + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-audio-1", { + outputAudio: null, + status: "idle", + error: null, + duration: null, + format: null, + }); + }); + }); + + describe("Regenerate Button", () => { + it("should show regenerate button when audio is complete", () => { + render( + + + + ); + + expect(screen.getByText("Regenerate")).toBeInTheDocument(); + }); + + it("should call regenerateNode when regenerate button is clicked", () => { + render( + + + + ); + + const regenButton = screen.getByText("Regenerate"); + fireEvent.click(regenButton); + + expect(mockRegenerateNode).toHaveBeenCalledWith("test-audio-1"); + }); + + it("should disable regenerate button when workflow is running", () => { + mockUseWorkflowStore.mockImplementation((selector) => { + const state = { + updateNodeData: mockUpdateNodeData, + regenerateNode: mockRegenerateNode, + incrementModalCount: mockIncrementModalCount, + decrementModalCount: mockDecrementModalCount, + providerSettings: defaultProviderSettings, + generationsPath: "/test/generations", + isRunning: true, + currentNodeIds: [], + groups: {}, + nodes: [], + getNodesWithComments: vi.fn(() => []), + markCommentViewed: vi.fn(), + setNavigationTarget: vi.fn(), + }; + return selector(state); + }); + + render( + + + + ); + + const regenButton = screen.getByText("Regenerate"); + expect(regenButton).toBeDisabled(); + }); + }); + + describe("Audio History Carousel", () => { + it("should not show carousel controls when history has only one item", () => { + render( + + + + ); + + expect(screen.queryByTitle("Previous")).not.toBeInTheDocument(); + expect(screen.queryByTitle("Next")).not.toBeInTheDocument(); + }); + + it("should show carousel controls when history has multiple items", () => { + render( + + + + ); + + expect(screen.getByTitle("Previous")).toBeInTheDocument(); + expect(screen.getByTitle("Next")).toBeInTheDocument(); + }); + + it("should show current position in carousel", () => { + render( + + + + ); + + expect(screen.getByText("2/3")).toBeInTheDocument(); + }); + }); + + 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); + + await waitFor(() => { + expect(screen.getByText("Browse Models")).toBeInTheDocument(); + }); + }); + }); + + describe("Fetch Models on Mount", () => { + it("should fetch models with audio capabilities", async () => { + render( + + + + ); + + await waitFor(() => { + const fetchCalls = mockFetch.mock.calls.filter(call => + typeof call[0] === 'string' && call[0].includes('text-to-audio') + ); + expect(fetchCalls.length).toBeGreaterThan(0); + }); + }); + }); + + 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("Dynamic Input Handles", () => { + it("should render dynamic handles when inputSchema is provided", () => { + const { container } = render( + + + + ); + + const textHandle = container.querySelector('[data-handletype="text"]'); + expect(textHandle).toBeInTheDocument(); + }); + + it("should show default text handle when no inputSchema", () => { + const { container } = render( + + + + ); + + const textHandle = container.querySelector('[data-handletype="text"]'); + expect(textHandle).toBeInTheDocument(); + }); + }); +});