You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

302 lines
9.2 KiB

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", () => {
const originalProviderMode = process.env.NEXT_PUBLIC_PROVIDER_MODE;
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation
mockUseWorkflowStore.mockImplementation((selector) => {
const state = {
updateNodeData: mockUpdateNodeData,
regenerateNode: mockRegenerateNode,
isRunning: false,
currentNodeIds: [],
groups: {},
nodes: [],
getNodesWithComments: vi.fn(() => []),
markCommentViewed: vi.fn(),
setNavigationTarget: vi.fn(),
};
return selector(state);
});
});
afterEach(() => {
if (originalProviderMode === undefined) {
delete process.env.NEXT_PUBLIC_PROVIDER_MODE;
} else {
process.env.NEXT_PUBLIC_PROVIDER_MODE = originalProviderMode;
}
window.localStorage.removeItem("node-banana-inline-parameters");
vi.restoreAllMocks();
});
const createNodeData = (overrides: Partial<LLMGenerateNodeData> = {}): LLMGenerateNodeData => ({
inputPrompt: null,
inputImages: [],
inputVideos: [],
outputText: null,
provider: "newapiwg",
model: "doubao-seed-2-0-lite-260428",
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 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 video input handle on left", () => {
const { container } = render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps()} />
</TestWrapper>
);
const videoHandle = container.querySelector('[data-handletype="video"][class*="target"]');
expect(videoHandle).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();
});
it("hides provider selector and fallback tabs in Popi provider mode", () => {
process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi";
window.localStorage.setItem("node-banana-inline-parameters", "true");
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps({
fallbackModel: {
provider: "anthropic",
modelId: "claude-sonnet-4.5",
displayName: "Claude Sonnet 4.5",
},
})} />
</TestWrapper>
);
expect(screen.queryByText("Provider")).not.toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
expect(screen.getByText("Model")).toBeInTheDocument();
});
});
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 the switched model display name for provider overload errors", () => {
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps({
status: "error",
model: "doubao-seed-2-0-pro-260215",
error: "doubao-seed-2-0-lite-260428: provider is temporarily overloaded. Please retry in a moment or switch models.",
})} />
</TestWrapper>
);
expect(screen.getByText(
"Doubao Seed 2.0 Pro: provider is temporarily overloaded. Please retry in a moment or switch models."
)).toBeInTheDocument();
});
it("should show 'Generation failed' when error message is null", () => {
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps({ status: "error", error: null })} />
</TestWrapper>
);
expect(screen.getByText("Generation 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,
currentNodeIds: [],
groups: {},
nodes: [],
getNodesWithComments: vi.fn(() => []),
markCommentViewed: vi.fn(),
setNavigationTarget: vi.fn(),
};
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,
});
});
});
});