Browse Source
- Basic rendering with title and handles (text/image input, text output) - Provider selector (Google, OpenAI) with model switching - Model selector dropdown based on selected provider - Temperature slider rendering and editing - Loading state with spinner - Error state display with fallback message - Output text display with regenerate and clear buttons - Custom title editing functionality - Model fallback when current model is invalid for provider Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
1 changed files with 403 additions and 0 deletions
@ -0,0 +1,403 @@ |
|||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
||||
|
import { render, screen, fireEvent } from "@testing-library/react"; |
||||
|
import { LLMGenerateNode } from "@/components/nodes/LLMGenerateNode"; |
||||
|
import { ReactFlowProvider } from "@xyflow/react"; |
||||
|
import { LLMGenerateNodeData } from "@/types"; |
||||
|
|
||||
|
// Mock the workflow store
|
||||
|
const mockUpdateNodeData = vi.fn(); |
||||
|
const mockRegenerateNode = 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); |
||||
|
}, |
||||
|
})); |
||||
|
|
||||
|
// Wrapper component for React Flow context
|
||||
|
function TestWrapper({ children }: { children: React.ReactNode }) { |
||||
|
return <ReactFlowProvider>{children}</ReactFlowProvider>; |
||||
|
} |
||||
|
|
||||
|
describe("LLMGenerateNode", () => { |
||||
|
beforeEach(() => { |
||||
|
vi.clearAllMocks(); |
||||
|
|
||||
|
// Default mock implementation
|
||||
|
mockUseWorkflowStore.mockImplementation((selector) => { |
||||
|
const state = { |
||||
|
updateNodeData: mockUpdateNodeData, |
||||
|
regenerateNode: mockRegenerateNode, |
||||
|
isRunning: false, |
||||
|
currentNodeId: null, |
||||
|
groups: {}, |
||||
|
nodes: [], |
||||
|
}; |
||||
|
return selector(state); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
vi.restoreAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
const createNodeData = (overrides: Partial<LLMGenerateNodeData> = {}): LLMGenerateNodeData => ({ |
||||
|
inputPrompt: null, |
||||
|
inputImages: [], |
||||
|
outputText: null, |
||||
|
provider: "google", |
||||
|
model: "gemini-3-flash-preview", |
||||
|
temperature: 1.0, |
||||
|
maxTokens: 2048, |
||||
|
status: "idle", |
||||
|
error: null, |
||||
|
...overrides, |
||||
|
}); |
||||
|
|
||||
|
const createNodeProps = (data: Partial<LLMGenerateNodeData> = {}) => ({ |
||||
|
id: "test-llm-1", |
||||
|
type: "llmGenerate" as const, |
||||
|
data: createNodeData(data), |
||||
|
selected: false, |
||||
|
}); |
||||
|
|
||||
|
describe("Basic Rendering", () => { |
||||
|
it("should render with title 'LLM Generate'", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("LLM Generate")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render text input handle on left", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const textHandle = container.querySelector('[data-handletype="text"][class*="target"]'); |
||||
|
expect(textHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render image input handle on left", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const imageHandle = container.querySelector('[data-handletype="image"][class*="target"]'); |
||||
|
expect(imageHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render text output handle on right", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const outputHandle = container.querySelector('[data-handletype="text"][class*="source"]'); |
||||
|
expect(outputHandle).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Provider Selector", () => { |
||||
|
it("should render provider selector with Google selected by default", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ provider: "google" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const providerSelect = screen.getByDisplayValue("Google"); |
||||
|
expect(providerSelect).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should show Google and OpenAI as provider options", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByRole("option", { name: "Google" })).toBeInTheDocument(); |
||||
|
expect(screen.getByRole("option", { name: "OpenAI" })).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData when provider is changed", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ provider: "google" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const providerSelect = screen.getByDisplayValue("Google"); |
||||
|
fireEvent.change(providerSelect, { target: { value: "openai" } }); |
||||
|
|
||||
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", { |
||||
|
provider: "openai", |
||||
|
model: "gpt-4.1-mini", |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Model Selector", () => { |
||||
|
it("should show Google models when Google provider is selected", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ provider: "google" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByRole("option", { name: "Gemini 3 Flash" })).toBeInTheDocument(); |
||||
|
expect(screen.getByRole("option", { name: "Gemini 2.5 Flash" })).toBeInTheDocument(); |
||||
|
expect(screen.getByRole("option", { name: "Gemini 3.0 Pro" })).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should show OpenAI models when OpenAI provider is selected", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ provider: "openai", model: "gpt-4.1-mini" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByRole("option", { name: "GPT-4.1 Mini" })).toBeInTheDocument(); |
||||
|
expect(screen.getByRole("option", { name: "GPT-4.1 Nano" })).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData when model is changed", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ provider: "google", model: "gemini-3-flash-preview" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const modelSelect = screen.getByDisplayValue("Gemini 3 Flash"); |
||||
|
fireEvent.change(modelSelect, { target: { value: "gemini-2.5-flash" } }); |
||||
|
|
||||
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", { |
||||
|
model: "gemini-2.5-flash", |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Temperature Slider", () => { |
||||
|
it("should render temperature slider with current value", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ temperature: 0.7 })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Temp: 0.7")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData when temperature is changed", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ temperature: 1.0 })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const slider = screen.getByRole("slider"); |
||||
|
fireEvent.change(slider, { target: { value: "1.5" } }); |
||||
|
|
||||
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", { |
||||
|
temperature: 1.5, |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Idle State", () => { |
||||
|
it("should show 'Run to generate' message when idle and no output", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ status: "idle", outputText: null })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Run to generate")).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Loading State", () => { |
||||
|
it("should show loading spinner when status is loading", () => { |
||||
|
const { container } = render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ status: "loading" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const spinner = container.querySelector(".animate-spin"); |
||||
|
expect(spinner).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Error State", () => { |
||||
|
it("should show error message when status is error", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ status: "error", error: "API rate limit exceeded" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("API rate limit exceeded")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should show 'Failed' when error message is null", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ status: "error", error: null })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Failed")).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Output Text Display", () => { |
||||
|
it("should display output text when data.outputText exists", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ outputText: "Generated response text" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText("Generated response text")).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should render regenerate button when output exists", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ outputText: "Some output" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const regenerateButton = screen.getByTitle("Regenerate"); |
||||
|
expect(regenerateButton).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call regenerateNode when regenerate button is clicked", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ outputText: "Some output" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const regenerateButton = screen.getByTitle("Regenerate"); |
||||
|
fireEvent.click(regenerateButton); |
||||
|
|
||||
|
expect(mockRegenerateNode).toHaveBeenCalledWith("test-llm-1"); |
||||
|
}); |
||||
|
|
||||
|
it("should disable regenerate button when workflow is running", () => { |
||||
|
mockUseWorkflowStore.mockImplementation((selector) => { |
||||
|
const state = { |
||||
|
updateNodeData: mockUpdateNodeData, |
||||
|
regenerateNode: mockRegenerateNode, |
||||
|
isRunning: true, |
||||
|
currentNodeId: null, |
||||
|
groups: {}, |
||||
|
nodes: [], |
||||
|
}; |
||||
|
return selector(state); |
||||
|
}); |
||||
|
|
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ outputText: "Some output" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const regenerateButton = screen.getByTitle("Regenerate"); |
||||
|
expect(regenerateButton).toBeDisabled(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Clear Output Button", () => { |
||||
|
it("should render clear output button when output exists", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ outputText: "Some output" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const clearButton = screen.getByTitle("Clear output"); |
||||
|
expect(clearButton).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData to clear output when clear button is clicked", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ outputText: "Some output" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
const clearButton = screen.getByTitle("Clear output"); |
||||
|
fireEvent.click(clearButton); |
||||
|
|
||||
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", { |
||||
|
outputText: null, |
||||
|
status: "idle", |
||||
|
error: null, |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Custom Title and Comment", () => { |
||||
|
it("should display custom title when provided", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ customTitle: "My LLM" })} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
expect(screen.getByText(/My LLM/)).toBeInTheDocument(); |
||||
|
}); |
||||
|
|
||||
|
it("should call updateNodeData when custom title is changed", () => { |
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps()} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// Click on title to edit
|
||||
|
const title = screen.getByText("LLM Generate"); |
||||
|
fireEvent.click(title); |
||||
|
|
||||
|
const input = screen.getByPlaceholderText("Custom title..."); |
||||
|
fireEvent.change(input, { target: { value: "Custom LLM Title" } }); |
||||
|
fireEvent.keyDown(input, { key: "Enter" }); |
||||
|
|
||||
|
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", { customTitle: "Custom LLM Title" }); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("Model Fallback", () => { |
||||
|
it("should fall back to first available model when current model is invalid for provider", () => { |
||||
|
// When provider is google but model is an OpenAI model, should show first Google model
|
||||
|
render( |
||||
|
<TestWrapper> |
||||
|
<LLMGenerateNode {...createNodeProps({ |
||||
|
provider: "google", |
||||
|
model: "gpt-4.1-mini" as any // Invalid model for Google provider
|
||||
|
})} /> |
||||
|
</TestWrapper> |
||||
|
); |
||||
|
|
||||
|
// Should show first Google model in select
|
||||
|
const modelSelect = screen.getByDisplayValue("Gemini 3 Flash"); |
||||
|
expect(modelSelect).toBeInTheDocument(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue