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.
 
 

346 lines
11 KiB

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { LLMGenerateNode } from "@/components/nodes/LLMGenerateNode";
import { ReactFlowProvider } from "@xyflow/react";
import { LLMGenerateNodeData } from "@/types";
import type { ProviderModel } from "@/lib/providers/types";
// Mock the workflow store
const mockUpdateNodeData = vi.fn();
const mockRegenerateNode = vi.fn();
const mockUseWorkflowStore = vi.fn();
let mockPopiLlmModels: ProviderModel[] = [];
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: (selector?: (state: unknown) => unknown) => {
if (selector) {
return mockUseWorkflowStore(selector);
}
return mockUseWorkflowStore((s: unknown) => s);
},
}));
vi.mock("@/store/modelStore", () => ({
useModelStore: (selector: (state: unknown) => unknown) =>
selector({
byKind: {
image: [],
video: [],
audio: [],
"3d": [],
llm: mockPopiLlmModels,
multiAngle: [],
highDefinition: [],
outpainting: [],
inpainting: [],
videoEnhance: [],
videoSmartErase: [],
videoBoxErase: [],
},
detailsById: {},
detailLoadingById: {},
detailErrorById: {},
fetchModelDetail: vi.fn(),
}),
}));
// 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();
process.env.NEXT_PUBLIC_PROVIDER_MODE = "multi";
mockPopiLlmModels = [];
// Default mock implementation
mockUseWorkflowStore.mockImplementation((selector) => {
const state = {
updateNodeData: mockUpdateNodeData,
regenerateNode: mockRegenerateNode,
isRunning: false,
runningNodeIds: new Set<string>(),
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 generic input handle on left", () => {
const { container } = render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps()} />
</TestWrapper>
);
const inputHandle = container.querySelector('[data-handletype="generic-input"][class*="target"]');
expect(inputHandle).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 model selectors and inline config in the node", () => {
process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi";
window.localStorage.setItem("node-banana-inline-parameters", "true");
mockPopiLlmModels = [{
id: "32",
name: "speech-llm",
description: null,
provider: "popiserver",
capabilities: [],
metadata: { type: 5, subTypes: [501] },
}];
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.queryByText("Provider")).not.toBeInTheDocument();
expect(screen.queryByText("Model")).not.toBeInTheDocument();
expect(screen.queryByText(/Temperature:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Max Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText("温度")).not.toBeInTheDocument();
expect(screen.queryByText("最大 Token")).not.toBeInTheDocument();
});
it("syncs Popi LLM model ids from modelStore without rendering a model dropdown", async () => {
process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi";
window.localStorage.setItem("node-banana-inline-parameters", "true");
mockPopiLlmModels = [{
id: "37",
name: "Kimi Server Model",
description: null,
provider: "popiserver",
capabilities: [],
metadata: { type: 5, subTypes: [501] },
}];
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps()} />
</TestWrapper>
);
expect(screen.queryByDisplayValue("Kimi Server Model")).not.toBeInTheDocument();
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-llm-1", expect.objectContaining({
provider: "popiserver",
model: "37",
selectedModel: expect.objectContaining({ modelId: "37" }),
}));
});
});
});
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((content) =>
content.includes("Doubao Seed 2.0 Pro") &&
(content.includes("overloaded") || content.includes("过载"))
)).toBeInTheDocument();
});
it("should show 'Generation failed' when error message is null", () => {
render(
<TestWrapper>
<LLMGenerateNode {...createNodeProps({ status: "error", error: null })} />
</TestWrapper>
);
expect(screen.getByText((content) =>
content === "Generation failed" || content === "生成失败"
)).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,
runningNodeIds: new Set<string>(["test-llm-1"]),
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,
});
});
});
});