Files
Alfred/tests/web/routes/models.test.tsx

343 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});