feat: 拆分模型/供应商为独立路由页面,侧边栏支持 SubMenu 分组
This commit is contained in:
@@ -4,9 +4,9 @@ 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 { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
||||
|
||||
const ENABLED_PROVIDER: Provider = {
|
||||
apiKey: "sk-test",
|
||||
@@ -45,107 +45,6 @@ function clickLatestConfirmButton() {
|
||||
fireEvent.click(buttons[buttons.length - 1]!);
|
||||
}
|
||||
|
||||
describe("ProviderFormModal", () => {
|
||||
test("编辑供应商表单只提交变更字段", async () => {
|
||||
const updateCalls: unknown[] = [];
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProviderFormModal, {
|
||||
editingProvider: ENABLED_PROVIDER,
|
||||
onCancel: () => undefined,
|
||||
onCreate: () => Promise.resolve(),
|
||||
onOpenChange: () => undefined,
|
||||
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
|
||||
onUpdate: (args: unknown) => {
|
||||
updateCalls.push(args);
|
||||
return Promise.resolve();
|
||||
},
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "New OpenAI" } });
|
||||
clickLatestConfirmButton();
|
||||
|
||||
await waitFor(() => expect(updateCalls.length).toBe(1));
|
||||
expect(updateCalls[0]).toEqual({ data: { name: "New OpenAI" }, id: "pv1" });
|
||||
});
|
||||
|
||||
test("新建供应商默认使用 openai-compatible 类型", async () => {
|
||||
const createCalls: unknown[] = [];
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProviderFormModal, {
|
||||
editingProvider: null,
|
||||
onCancel: () => undefined,
|
||||
onCreate: (data: unknown) => {
|
||||
createCalls.push(data);
|
||||
return Promise.resolve();
|
||||
},
|
||||
onOpenChange: () => undefined,
|
||||
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
|
||||
onUpdate: () => Promise.resolve(),
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
|
||||
target: { value: "https://api.test.com/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
|
||||
clickLatestConfirmButton();
|
||||
|
||||
await waitFor(() => expect(createCalls.length).toBe(1));
|
||||
expect(createCalls[0]).toEqual({
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
name: "兼容供应商",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
});
|
||||
|
||||
test("供应商表单可使用当前表单配置测试连接", async () => {
|
||||
const testCalls: unknown[] = [];
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProviderFormModal, {
|
||||
editingProvider: null,
|
||||
onCancel: () => undefined,
|
||||
onCreate: () => Promise.resolve(),
|
||||
onOpenChange: () => undefined,
|
||||
onTest: (data: unknown) => {
|
||||
testCalls.push(data);
|
||||
return Promise.resolve({ message: "连接成功", ok: true });
|
||||
},
|
||||
onUpdate: () => Promise.resolve(),
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
|
||||
target: { value: "https://api.test.com/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
|
||||
|
||||
await waitFor(() => expect(testCalls.length).toBe(1));
|
||||
expect(testCalls[0]).toEqual({
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
name: "兼容供应商",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelFormModal", () => {
|
||||
test("编辑模型表单只提交变更字段", async () => {
|
||||
const updateCalls: unknown[] = [];
|
||||
@@ -317,3 +216,112 @@ describe("ModelFormModal", () => {
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "测试连接" })).not.toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
id: "m1",
|
||||
maxOutputTokens: 4096,
|
||||
modelId: "gpt-4o",
|
||||
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.modelId}`.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 waitFor(() => {
|
||||
expect(screen.getByText("GPT-4o")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("搜索模型名称或 ID")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull();
|
||||
expect(calls.some((call) => call.url.includes("/api/models"))).toBe(true);
|
||||
}, 15000);
|
||||
|
||||
test("搜索模型更新请求参数", async () => {
|
||||
const calls = createModelFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
||||
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索模型名称或 ID"), { target: { value: "gpt" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
|
||||
}, 15000);
|
||||
|
||||
test("新建模型弹窗可以打开", async () => {
|
||||
createModelFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/models" });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /新建模型/ }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入模型名称")).not.toBeNull());
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
222
tests/web/routes/providers.test.tsx
Normal file
222
tests/web/routes/providers.test.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Provider } from "../../../src/shared/api";
|
||||
|
||||
import { App } from "../../../src/web/app";
|
||||
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
|
||||
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",
|
||||
};
|
||||
|
||||
function clickLatestConfirmButton() {
|
||||
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
|
||||
fireEvent.click(buttons[buttons.length - 1]!);
|
||||
}
|
||||
|
||||
describe("ProviderFormModal", () => {
|
||||
test("编辑供应商表单只提交变更字段", async () => {
|
||||
const updateCalls: unknown[] = [];
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProviderFormModal, {
|
||||
editingProvider: ENABLED_PROVIDER,
|
||||
onCancel: () => undefined,
|
||||
onCreate: () => Promise.resolve(),
|
||||
onOpenChange: () => undefined,
|
||||
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
|
||||
onUpdate: (args: unknown) => {
|
||||
updateCalls.push(args);
|
||||
return Promise.resolve();
|
||||
},
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "New OpenAI" } });
|
||||
clickLatestConfirmButton();
|
||||
|
||||
await waitFor(() => expect(updateCalls.length).toBe(1));
|
||||
expect(updateCalls[0]).toEqual({ data: { name: "New OpenAI" }, id: "pv1" });
|
||||
});
|
||||
|
||||
test("新建供应商默认使用 openai-compatible 类型", async () => {
|
||||
const createCalls: unknown[] = [];
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProviderFormModal, {
|
||||
editingProvider: null,
|
||||
onCancel: () => undefined,
|
||||
onCreate: (data: unknown) => {
|
||||
createCalls.push(data);
|
||||
return Promise.resolve();
|
||||
},
|
||||
onOpenChange: () => undefined,
|
||||
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
|
||||
onUpdate: () => Promise.resolve(),
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
|
||||
target: { value: "https://api.test.com/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
|
||||
clickLatestConfirmButton();
|
||||
|
||||
await waitFor(() => expect(createCalls.length).toBe(1));
|
||||
expect(createCalls[0]).toEqual({
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
name: "兼容供应商",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
});
|
||||
|
||||
test("供应商表单可使用当前表单配置测试连接", async () => {
|
||||
const testCalls: unknown[] = [];
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProviderFormModal, {
|
||||
editingProvider: null,
|
||||
onCancel: () => undefined,
|
||||
onCreate: () => Promise.resolve(),
|
||||
onOpenChange: () => undefined,
|
||||
onTest: (data: unknown) => {
|
||||
testCalls.push(data);
|
||||
return Promise.resolve({ message: "连接成功", ok: true });
|
||||
},
|
||||
onUpdate: () => Promise.resolve(),
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
|
||||
target: { value: "https://api.test.com/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
|
||||
|
||||
await waitFor(() => expect(testCalls.length).toBe(1));
|
||||
expect(testCalls[0]).toEqual({
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://api.test.com/v1",
|
||||
name: "兼容供应商",
|
||||
type: "openai-compatible",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
function createProviderFetchMock() {
|
||||
let providers = [TEST_PROVIDER];
|
||||
|
||||
return installFetchMock((call) => {
|
||||
if (call.url.includes("/api/meta")) return mockMetaResponse();
|
||||
|
||||
const url = new URL(call.url, "http://localhost");
|
||||
|
||||
if (url.pathname === "/api/providers" && call.method === "POST") {
|
||||
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
|
||||
const created: Provider = {
|
||||
...TEST_PROVIDER,
|
||||
...data,
|
||||
createdAt: "2024-01-02T00:00:00.000Z",
|
||||
id: "pv-new",
|
||||
updatedAt: "2024-01-02T00:00:00.000Z",
|
||||
};
|
||||
providers = [created, ...providers];
|
||||
return jsonResponse({ provider: created }, { status: 201 });
|
||||
}
|
||||
|
||||
if (/^\/api\/providers\/[^/]+$/.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 = providers.find((p) => p.id === id) ?? TEST_PROVIDER;
|
||||
const updated = { ...existing, ...(data as Partial<Provider>) };
|
||||
providers = providers.map((p) => (p.id === id ? updated : p));
|
||||
return jsonResponse({ provider: updated });
|
||||
}
|
||||
|
||||
if (/^\/api\/providers\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
|
||||
const id = url.pathname.split("/").pop()!;
|
||||
providers = providers.filter((p) => p.id !== id);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/providers" && call.method === "GET") {
|
||||
const keyword = url.searchParams.get("keyword") ?? "";
|
||||
const items = keyword ? providers.filter((p) => p.name.includes(keyword)) : providers;
|
||||
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
|
||||
}
|
||||
|
||||
if (/\/api\/providers\/[^/]+\/test$/.exec(url.pathname) && call.method === "POST") {
|
||||
return jsonResponse({ message: "连接成功", ok: true });
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "Not Found" }, { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProviderListPage", () => {
|
||||
test("渲染供应商列表页并请求供应商数据", async () => {
|
||||
const calls = createProviderFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByPlaceholderText("搜索供应商名称")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull();
|
||||
expect(calls.some((call) => call.url.includes("/api/providers"))).toBe(true);
|
||||
}, 15000);
|
||||
|
||||
test("搜索供应商更新请求参数", async () => {
|
||||
const calls = createProviderFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
|
||||
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索供应商名称"), { target: { value: "Open" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
|
||||
}, 15000);
|
||||
|
||||
test("新建供应商弹窗可以打开", async () => {
|
||||
createProviderFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /新建供应商/ }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
|
||||
}, 15000);
|
||||
});
|
||||
Reference in New Issue
Block a user