343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { describe, expect, mock, test } from "bun:test";
|
||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||
import { createElement } from "react";
|
||
|
||
import type { Model, Provider } from "../../../src/shared/api";
|
||
|
||
import { App } from "../../../src/web/app";
|
||
import { ModelFormModal } from "../../../src/web/features/models/components/ModelFormModal";
|
||
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
||
|
||
const ENABLED_PROVIDER: Provider = {
|
||
apiKey: "sk-test",
|
||
baseUrl: "https://api.openai.com/v1",
|
||
createdAt: "2024-01-01T00:00:00.000Z",
|
||
id: "pv1",
|
||
name: "OpenAI",
|
||
type: "openai",
|
||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||
};
|
||
|
||
const DISABLED_PROVIDER: Provider = {
|
||
apiKey: "sk-off",
|
||
baseUrl: "https://api.deepseek.com/v1",
|
||
createdAt: "2024-01-01T00:00:00.000Z",
|
||
id: "pv2",
|
||
name: "DeepSeek",
|
||
type: "openai-compatible",
|
||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||
};
|
||
|
||
const ENABLED_MODEL: Model = {
|
||
capabilities: ["text", "reasoning"],
|
||
contextLength: 128000,
|
||
createdAt: "2024-01-01T00:00:00.000Z",
|
||
externalId: "gpt-4o",
|
||
id: "m1",
|
||
maxOutputTokens: 4096,
|
||
name: "GPT-4o",
|
||
providerId: "pv1",
|
||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||
};
|
||
|
||
function clickLatestConfirmButton() {
|
||
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
|
||
fireEvent.click(buttons[buttons.length - 1]!);
|
||
}
|
||
|
||
describe("ModelFormModal", () => {
|
||
test(
|
||
"编辑模型表单只提交变更字段",
|
||
async () => {
|
||
const updateCalls: unknown[] = [];
|
||
|
||
renderWithProviders(
|
||
createElement(ModelFormModal, {
|
||
editingModel: ENABLED_MODEL,
|
||
onCancel: () => undefined,
|
||
onCreate: () => Promise.resolve(),
|
||
onOpenChange: () => undefined,
|
||
onUpdate: (args: unknown) => {
|
||
updateCalls.push(args);
|
||
return Promise.resolve();
|
||
},
|
||
open: true,
|
||
providers: [ENABLED_PROVIDER, DISABLED_PROVIDER],
|
||
providersError: null,
|
||
providersLoading: false,
|
||
submitting: false,
|
||
}),
|
||
);
|
||
|
||
await screen.findByPlaceholderText("请输入模型名称");
|
||
fireEvent.change(screen.getByPlaceholderText("请输入模型名称"), { target: { value: "GPT-4o Mini" } });
|
||
clickLatestConfirmButton();
|
||
|
||
await waitFor(() => expect(updateCalls.length).toBe(1));
|
||
expect(updateCalls[0]).toEqual({ data: { name: "GPT-4o Mini" }, id: "m1" });
|
||
},
|
||
{ timeout: 15000 },
|
||
);
|
||
|
||
test("模型表单校验失败不会提交", async () => {
|
||
const onCreate = mock(() => Promise.resolve());
|
||
|
||
renderWithProviders(
|
||
createElement(ModelFormModal, {
|
||
editingModel: null,
|
||
onCancel: () => undefined,
|
||
onCreate,
|
||
onOpenChange: () => undefined,
|
||
onUpdate: () => Promise.resolve(),
|
||
open: true,
|
||
providers: [ENABLED_PROVIDER],
|
||
providersError: null,
|
||
providersLoading: false,
|
||
submitting: false,
|
||
}),
|
||
);
|
||
|
||
await screen.findByPlaceholderText("请输入模型名称");
|
||
clickLatestConfirmButton();
|
||
expect(onCreate).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test("新建模型默认选中文本和推理能力", async () => {
|
||
renderWithProviders(
|
||
createElement(ModelFormModal, {
|
||
editingModel: null,
|
||
onCancel: () => undefined,
|
||
onCreate: () => Promise.resolve(),
|
||
onOpenChange: () => undefined,
|
||
onUpdate: () => Promise.resolve(),
|
||
open: true,
|
||
providers: [ENABLED_PROVIDER],
|
||
providersError: null,
|
||
providersLoading: false,
|
||
submitting: false,
|
||
}),
|
||
);
|
||
|
||
await screen.findByLabelText("文本");
|
||
const textCheckbox = screen.getByLabelText("文本");
|
||
const reasoningCheckbox = screen.getByLabelText("推理");
|
||
expect((textCheckbox as { checked?: boolean }).checked).toBe(true);
|
||
expect((reasoningCheckbox as { checked?: boolean }).checked).toBe(true);
|
||
});
|
||
|
||
test(
|
||
"新建模型展示供应商 options 列表",
|
||
async () => {
|
||
renderWithProviders(
|
||
createElement(ModelFormModal, {
|
||
editingModel: null,
|
||
onCancel: () => undefined,
|
||
onCreate: () => Promise.resolve(),
|
||
onOpenChange: () => undefined,
|
||
onUpdate: () => Promise.resolve(),
|
||
open: true,
|
||
providers: [ENABLED_PROVIDER, DISABLED_PROVIDER],
|
||
providersError: null,
|
||
providersLoading: false,
|
||
submitting: false,
|
||
}),
|
||
);
|
||
|
||
await screen.findByPlaceholderText("请输入模型名称");
|
||
fireEvent.mouseDown(screen.getByRole("combobox"));
|
||
|
||
expect(await screen.findByText("OpenAI")).not.toBeNull();
|
||
expect(await screen.findByText("DeepSeek")).not.toBeNull();
|
||
},
|
||
{ timeout: 15000 },
|
||
);
|
||
|
||
test(
|
||
"供应商下拉展示加载错误提示",
|
||
async () => {
|
||
renderWithProviders(
|
||
createElement(ModelFormModal, {
|
||
editingModel: null,
|
||
onCancel: () => undefined,
|
||
onCreate: () => Promise.resolve(),
|
||
onOpenChange: () => undefined,
|
||
onUpdate: () => Promise.resolve(),
|
||
open: true,
|
||
providers: [],
|
||
providersError: new Error("options failed"),
|
||
providersLoading: false,
|
||
submitting: false,
|
||
}),
|
||
);
|
||
|
||
await screen.findByPlaceholderText("请输入模型名称");
|
||
fireEvent.mouseDown(screen.getByRole("combobox"));
|
||
|
||
expect(await screen.findByText("供应商加载失败:options failed")).not.toBeNull();
|
||
},
|
||
{ timeout: 15000 },
|
||
);
|
||
|
||
test(
|
||
"编辑模型时可测试模型连接",
|
||
async () => {
|
||
const testModelConnection = mock(() => Promise.resolve({ message: "模型连接成功", ok: true }));
|
||
|
||
renderWithProviders(
|
||
createElement(ModelFormModal, {
|
||
editingModel: ENABLED_MODEL,
|
||
onCancel: () => undefined,
|
||
onCreate: () => Promise.resolve(),
|
||
onOpenChange: () => undefined,
|
||
onUpdate: () => Promise.resolve(),
|
||
open: true,
|
||
providers: [ENABLED_PROVIDER],
|
||
providersError: null,
|
||
providersLoading: false,
|
||
submitting: false,
|
||
testModelConnection,
|
||
}),
|
||
);
|
||
|
||
await screen.findByRole("button", { name: "测试连接" });
|
||
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
|
||
|
||
await waitFor(() =>
|
||
expect(testModelConnection).toHaveBeenCalledWith({
|
||
externalId: "gpt-4o",
|
||
providerId: "pv1",
|
||
}),
|
||
);
|
||
},
|
||
{ timeout: 15000 },
|
||
);
|
||
|
||
test("新建模型也显示测试连接按钮", async () => {
|
||
renderWithProviders(
|
||
createElement(ModelFormModal, {
|
||
editingModel: null,
|
||
onCancel: () => undefined,
|
||
onCreate: () => Promise.resolve(),
|
||
onOpenChange: () => undefined,
|
||
onUpdate: () => Promise.resolve(),
|
||
open: true,
|
||
providers: [ENABLED_PROVIDER],
|
||
providersError: null,
|
||
providersLoading: false,
|
||
submitting: false,
|
||
testModelConnection: () => Promise.resolve({ message: "ok", ok: true }),
|
||
}),
|
||
);
|
||
|
||
await screen.findByRole("button", { name: "测试连接" });
|
||
});
|
||
});
|
||
|
||
const TEST_PROVIDER: Provider = {
|
||
apiKey: "sk-test",
|
||
baseUrl: "https://api.openai.com/v1",
|
||
createdAt: "2024-01-01T00:00:00.000Z",
|
||
id: "pv1",
|
||
name: "OpenAI",
|
||
type: "openai",
|
||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||
};
|
||
|
||
const TEST_MODEL: Model = {
|
||
capabilities: ["text"],
|
||
contextLength: 128000,
|
||
createdAt: "2024-01-01T00:00:00.000Z",
|
||
externalId: "gpt-4o",
|
||
id: "m1",
|
||
maxOutputTokens: 4096,
|
||
name: "GPT-4o",
|
||
providerId: "pv1",
|
||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||
};
|
||
|
||
function createModelFetchMock() {
|
||
let models = [TEST_MODEL];
|
||
|
||
return installFetchMock((call) => {
|
||
if (call.url.includes("/api/meta")) return mockMetaResponse();
|
||
|
||
const url = new URL(call.url, "http://localhost");
|
||
|
||
if (url.pathname === "/api/providers/options" && call.method === "GET") {
|
||
return jsonResponse({ items: [TEST_PROVIDER] });
|
||
}
|
||
|
||
if (url.pathname === "/api/models" && call.method === "POST") {
|
||
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
|
||
const created: Model = {
|
||
...TEST_MODEL,
|
||
...data,
|
||
createdAt: "2024-01-02T00:00:00.000Z",
|
||
id: "m-new",
|
||
updatedAt: "2024-01-02T00:00:00.000Z",
|
||
};
|
||
models = [created, ...models];
|
||
return jsonResponse({ model: created }, { status: 201 });
|
||
}
|
||
|
||
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "PATCH") {
|
||
const id = url.pathname.split("/").pop()!;
|
||
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
|
||
const existing = models.find((m) => m.id === id) ?? TEST_MODEL;
|
||
const updated = { ...existing, ...(data as Partial<Model>) };
|
||
models = models.map((m) => (m.id === id ? updated : m));
|
||
return jsonResponse({ model: updated });
|
||
}
|
||
|
||
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
|
||
const id = url.pathname.split("/").pop()!;
|
||
models = models.filter((m) => m.id !== id);
|
||
return new Response(null, { status: 204 });
|
||
}
|
||
|
||
if (url.pathname === "/api/models" && call.method === "GET") {
|
||
const keyword = url.searchParams.get("keyword") ?? "";
|
||
const items = keyword ? models.filter((m) => `${m.name}${m.externalId}`.includes(keyword)) : models;
|
||
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
|
||
}
|
||
|
||
return jsonResponse({ error: "Not Found" }, { status: 404 });
|
||
});
|
||
}
|
||
|
||
describe("ModelListPage", () => {
|
||
test("渲染模型列表页并请求模型数据", async () => {
|
||
const calls = createModelFetchMock();
|
||
|
||
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
||
|
||
await screen.findByText("GPT-4o");
|
||
|
||
expect(screen.getByPlaceholderText("搜索模型名称或 ID")).not.toBeNull();
|
||
expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull();
|
||
expect(calls.some((call) => call.url.includes("/api/models"))).toBe(true);
|
||
}, 30000);
|
||
|
||
test("搜索模型更新请求参数", async () => {
|
||
const calls = createModelFetchMock();
|
||
|
||
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
||
await screen.findByText("GPT-4o");
|
||
|
||
const input = screen.getByPlaceholderText("搜索模型名称或 ID");
|
||
fireEvent.change(input, { target: { value: "gpt" } });
|
||
fireEvent.keyDown(input, { key: "Enter" });
|
||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
|
||
}, 30000);
|
||
|
||
test("新建模型弹窗可以打开", async () => {
|
||
createModelFetchMock();
|
||
|
||
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
||
await screen.findByRole("button", { name: /新建模型/ });
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: /新建模型/ }));
|
||
await screen.findByPlaceholderText("请输入模型名称");
|
||
}, 30000);
|
||
});
|