Browse Source
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 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 516 additions and 0 deletions
@ -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<typeof fetch>) => 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 <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 }, |
||||
|
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> = {}): GenerateAudioNodeData => ({ |
||||
|
inputPrompt: null, |
||||
|
outputAudio: null, |
||||
|
status: "idle", |
||||
|
error: null, |
||||
|
audioHistory: [], |
||||
|
selectedAudioHistoryIndex: 0, |
||||
|
duration: null, |
||||
|
format: null, |
||||
|
...overrides, |
||||
|
}); |
||||
|
|
||||
|
const createNodeProps = (data: Partial<GenerateAudioNodeData> = {}) => ({ |
||||
|
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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Generate Audio")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render model name as title when selectedModel is set", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
selectedModel: { provider: "kie", modelId: "elevenlabs-turbo-v2.5", displayName: "ElevenLabs Turbo v2.5" }, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("ElevenLabs Turbo v2.5")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render text input handle", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const textHandle = container.querySelector('[data-handletype="text"]'); |
||||
|
expect(textHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render audio output handle", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const outputHandle = container.querySelector('[data-handletype="audio"]'); |
||||
|
expect(outputHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Provider Selection", () => { |
||||
|
it("should show fal.ai as default provider", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// 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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
selectedModel: { provider: "fal", modelId: "", displayName: "Select model..." }, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// 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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ status: "loading" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
status: "error", |
||||
|
error: "Audio generation failed", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Audio generation failed")).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Audio Output", () => { |
||||
|
it("should render play button when audio is present", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
status: "complete", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const playButton = screen.getByTitle("Play"); |
||||
|
expect(playButton).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render clear button when audio is present", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
status: "complete", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const clearButton = screen.getByTitle("Clear audio"); |
||||
|
expect(clearButton).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData to clear audio when clear button is clicked", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
status: "complete", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
status: "complete", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Regenerate")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call regenerateNode when regenerate button is clicked", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
status: "complete", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
status: "complete", |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
audioHistory: [{ id: "audio1", timestamp: Date.now(), prompt: "test", model: "elevenlabs" }], |
||||
|
selectedAudioHistoryIndex: 0, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.queryByTitle("Previous")).not.toBeInTheDocument(); |
||||
|
expect(screen.queryByTitle("Next")).not.toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should show carousel controls when history has multiple items", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
audioHistory: [ |
||||
|
{ id: "audio1", timestamp: Date.now(), prompt: "test1", model: "elevenlabs" }, |
||||
|
{ id: "audio2", timestamp: Date.now(), prompt: "test2", model: "elevenlabs" }, |
||||
|
], |
||||
|
selectedAudioHistoryIndex: 0, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByTitle("Previous")).toBeInTheDocument(); |
||||
|
expect(screen.getByTitle("Next")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should show current position in carousel", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
outputAudio: "data:audio/mp3;base64,abc123", |
||||
|
audioHistory: [ |
||||
|
{ id: "audio1", timestamp: Date.now(), prompt: "test1", model: "elevenlabs" }, |
||||
|
{ id: "audio2", timestamp: Date.now(), prompt: "test2", model: "elevenlabs" }, |
||||
|
{ id: "audio3", timestamp: Date.now(), prompt: "test3", model: "elevenlabs" }, |
||||
|
], |
||||
|
selectedAudioHistoryIndex: 1, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("2/3")).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Browse Button", () => { |
||||
|
it("should render Browse button", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Browse")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should open ModelSearchDialog when Browse button is clicked", async () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
selectedModel: { provider: "fal", modelId: "", displayName: "Select model..." }, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
selectedModel: { provider: "kie", modelId: "elevenlabs-turbo-v2.5", displayName: "ElevenLabs Turbo v2.5" }, |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// 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( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps({ |
||||
|
selectedModel: { provider: "kie", modelId: "elevenlabs-turbo-v2.5", displayName: "ElevenLabs" }, |
||||
|
inputSchema: [ |
||||
|
{ name: "prompt", type: "text", required: true, label: "Text Prompt" }, |
||||
|
], |
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const textHandle = container.querySelector('[data-handletype="text"]'); |
||||
|
expect(textHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should show default text handle when no inputSchema", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<GenerateAudioNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const textHandle = container.querySelector('[data-handletype="text"]'); |
||||
|
expect(textHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue