Compare commits
2 Commits
b1dec691e9
...
2cdbe474ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cdbe474ce | |||
| 83349bf01b |
129
context.md
129
context.md
@@ -1,129 +0,0 @@
|
||||
# add-model-management 当前进度
|
||||
|
||||
## 变更信息
|
||||
|
||||
- **Change**: `openspec/changes/add-model-management/`
|
||||
- **Workflow**: fast-drive (design.md + tasks.md)
|
||||
- **状态**: apply-review 修复进行中,有一个阻塞问题
|
||||
|
||||
## 已完成工作
|
||||
|
||||
### 核心功能(54/54 tasks 全部标记 [x])
|
||||
|
||||
所有代码已实现并通过验证:
|
||||
|
||||
- DB Schema: providers + models 表(含 FK、唯一索引)
|
||||
- 数据访问层: `src/server/db/providers.ts`, `src/server/db/models.ts`
|
||||
- 后端路由: 15 个路由(providers 8 + models 7)注册在 `src/server/server.ts`
|
||||
- AI 服务层: `src/server/ai/registry.ts`(含 `testProviderConnection` + `buildProviderRegistry`), `src/server/ai/types.ts`
|
||||
- 共享类型: `src/shared/api.ts`(含 `MODEL_CAPABILITIES` 常量)
|
||||
- 前端 Hooks: `src/web/hooks/use-providers.ts`, `src/web/hooks/use-models.ts`
|
||||
- 前端页面: `src/web/pages/models/`(6 个组件 + index)
|
||||
- 前端路由+菜单: `src/web/routes.tsx`, `src/web/consoles/admin/menu.tsx`
|
||||
- 测试: 10 个测试文件(66 个测试用例)
|
||||
- 文档: backend.md, frontend.md, architecture.md, README.md 已更新
|
||||
|
||||
### apply-review 修复(进行中)
|
||||
|
||||
已完成的修复:
|
||||
|
||||
- ✅ **registry.ts 补充 `buildProviderRegistry`**: 新增从 DB 查询启用供应商构建 AI SDK Provider Registry 的函数
|
||||
- ✅ **Q1 统一错误响应**: `providers/get.ts` 和 `models/get.ts` 改用 `createApiError()`
|
||||
- ✅ **Q2 提取共享常量**: `MODEL_CAPABILITIES` 从 `shared/api.ts` 导出,`models/create.ts` 和 `models/update.ts` 不再重复定义
|
||||
- ✅ **Q3 清理重复测试**: `tests/web/hooks/use-models.test.ts` 移除了残留的 provider 相关测试
|
||||
- ✅ **文档修正**: `docs/development/backend.md` 中 `buildProviderRegistry` 签名已更新为 `(db)` 而非 `(config)`
|
||||
- ✅ **design.md + tasks.md 更新**: tasks 6.1 和 design 执行计划第 12 项已反映 registry 完整范围
|
||||
|
||||
## 阻塞问题
|
||||
|
||||
### `bun test` 无法解析 `createProviderRegistry`
|
||||
|
||||
**现象**: 运行 `bun test tests/server/ai/registry.test.ts` 时报错:
|
||||
|
||||
```
|
||||
SyntaxError: Export named 'createProviderRegistry' not found in module '...\node_modules\ai\dist\index.mjs'
|
||||
```
|
||||
|
||||
**已确认**:
|
||||
|
||||
- `createProviderRegistry` 在 `node_modules/ai/dist/index.mjs` 中存在
|
||||
- `bun -e "import { createProviderRegistry } from 'ai'; ..."` 正常工作
|
||||
- `bun run typecheck` 通过(类型存在)
|
||||
- 问题仅出现在 `bun test` 环境
|
||||
- 此前 registry.ts 只导入 `generateText` 时测试正常;添加 `createProviderRegistry` 后全部 registry 测试失败
|
||||
|
||||
**可能原因**: Bun 1.3.14 的 `bun test` ESM 模块解析缓存问题,或 `mock.module("ai", ...)` 与静态导入 `createProviderRegistry` 的交互问题
|
||||
|
||||
**建议尝试方向**:
|
||||
|
||||
1. 清除 Bun 缓存: `rm -rf node_modules/.cache`
|
||||
2. 升级 Bun 版本
|
||||
3. 改用动态导入 `const { createProviderRegistry } = await import("ai")` 在 `buildProviderRegistry` 函数内部
|
||||
4. 将 `buildProviderRegistry` 的测试改为不 mock `ai` 模块(因为 `createProviderRegistry` 不需要 mock,只有 `generateText` 需要)
|
||||
5. 将 registry 拆为两个文件:`connection-test.ts`(mock generateText)和 `registry.ts`(不 mock)
|
||||
|
||||
## 质量状态
|
||||
|
||||
- `bun run typecheck`: ✅ 0 errors
|
||||
- `bun run lint`: ✅ 0 errors(修复后)
|
||||
- 模型管理相关测试: ❌ 62/66 pass(4 个 registry 测试因上述问题失败,其他 62 个全部通过)
|
||||
- 已有 projects.test.tsx 有一个预存超时问题(与本次变更无关)
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增文件(untracked)
|
||||
|
||||
```
|
||||
drizzle/0001_wooden_rocket_raccoon.sql
|
||||
drizzle/meta/0001_snapshot.json
|
||||
src/server/ai/registry.ts
|
||||
src/server/ai/types.ts
|
||||
src/server/db/models.ts
|
||||
src/server/db/providers.ts
|
||||
src/server/routes/models/{create,delete,disable,enable,get,list,update}.ts
|
||||
src/server/routes/providers/{create,delete,disable,enable,get,list,test,update}.ts
|
||||
src/web/hooks/use-models.ts
|
||||
src/web/hooks/use-providers.ts
|
||||
src/web/pages/models/index.tsx
|
||||
src/web/pages/models/components/{ModelFormModal,ModelTable,ModelToolbar,ProviderFormModal,ProviderTable,ProviderToolbar}.tsx
|
||||
tests/server/ai/registry.test.ts
|
||||
tests/server/db/models.test.ts
|
||||
tests/server/db/providers.test.ts
|
||||
tests/server/routes/models.test.ts
|
||||
tests/server/routes/providers.test.ts
|
||||
tests/web/components/ModelTable.test.tsx
|
||||
tests/web/components/ProviderTable.test.tsx
|
||||
tests/web/hooks/use-models.test.ts
|
||||
tests/web/hooks/use-providers.test.ts
|
||||
tests/web/routes/models.test.tsx
|
||||
```
|
||||
|
||||
### 修改文件(modified)
|
||||
|
||||
```
|
||||
bun.lock
|
||||
docs/development/README.md
|
||||
docs/development/architecture.md
|
||||
docs/development/backend.md
|
||||
docs/development/frontend.md
|
||||
drizzle/meta/_journal.json
|
||||
package.json
|
||||
src/server/db/schema.ts
|
||||
src/server/server.ts
|
||||
src/shared/api.ts
|
||||
src/web/consoles/admin/menu.tsx
|
||||
src/web/routes.tsx
|
||||
```
|
||||
|
||||
### OpenSpec 变更文档
|
||||
|
||||
```
|
||||
openspec/changes/add-model-management/design.md
|
||||
openspec/changes/add-model-management/tasks.md
|
||||
```
|
||||
|
||||
## 后续步骤
|
||||
|
||||
1. 解决 `bun test` 与 `createProviderRegistry` 的兼容问题
|
||||
2. 确保所有 66+ 测试通过
|
||||
3. 归档变更(`/opsx-archive`)
|
||||
67
src/web/features/inbox/components/AddMaterialModal.tsx
Normal file
67
src/web/features/inbox/components/AddMaterialModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { App as AntApp, DatePicker, Form, Input, Modal } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { Material } from "../types";
|
||||
|
||||
interface AddMaterialModalProps {
|
||||
onAdd: (material: Material) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
associatedDate: dayjs.Dayjs;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function AddMaterialModal({ onAdd, onOpenChange, open }: AddMaterialModalProps) {
|
||||
const { message } = AntApp.useApp();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.resetFields();
|
||||
}, [form, open]);
|
||||
|
||||
const handleFinish = (values: FormValues) => {
|
||||
const material: Material = {
|
||||
associatedDate: values.associatedDate.format("YYYY-MM-DD"),
|
||||
createdAt: new Date().toISOString(),
|
||||
description: values.description,
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
onAdd(material);
|
||||
message.success("素材已添加");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
destroyOnHidden
|
||||
okText="确定"
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onOk={() => void form.submit()}
|
||||
open={open}
|
||||
title="新增素材"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={{ associatedDate: dayjs() }}
|
||||
layout="vertical"
|
||||
onFinish={(values) => void handleFinish(values)}
|
||||
>
|
||||
<Form.Item
|
||||
label="描述"
|
||||
name="description"
|
||||
rules={[{ message: "请输入描述", required: true, whitespace: true }]}
|
||||
>
|
||||
<Input.TextArea maxLength={500} placeholder="请输入素材描述" />
|
||||
</Form.Item>
|
||||
<Form.Item label="关联时间" name="associatedDate" rules={[{ message: "请选择关联时间", required: true }]}>
|
||||
<DatePicker className="app-inbox-datepicker" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
37
src/web/features/inbox/components/MaterialCard.tsx
Normal file
37
src/web/features/inbox/components/MaterialCard.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
import { Button, Card, Flex, Typography } from "antd";
|
||||
|
||||
import type { Material } from "../types";
|
||||
|
||||
import { formatRelativeTime } from "../../../shared/utils/time";
|
||||
|
||||
interface MaterialCardProps {
|
||||
material: Material;
|
||||
onDelete: () => void;
|
||||
onSelect: () => void;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
|
||||
return (
|
||||
<Card className={selected ? "app-inbox-card-selected" : undefined} hoverable onClick={onSelect} size="small">
|
||||
<Typography.Paragraph ellipsis={{ rows: 3 }}>{material.description}</Typography.Paragraph>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Typography.Text type="secondary">
|
||||
{material.associatedDate} · {formatRelativeTime(material.createdAt)}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
aria-label="删除"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
size="small"
|
||||
type="text"
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
32
src/web/features/inbox/components/MaterialContent.tsx
Normal file
32
src/web/features/inbox/components/MaterialContent.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, Descriptions, Empty, Typography } from "antd";
|
||||
|
||||
import type { Material } from "../types";
|
||||
|
||||
import { formatRelativeTime } from "../../../shared/utils/time";
|
||||
|
||||
interface MaterialContentProps {
|
||||
material: Material | null;
|
||||
}
|
||||
|
||||
export function MaterialContent({ material }: MaterialContentProps) {
|
||||
if (!material) {
|
||||
return (
|
||||
<div className="app-inbox-content">
|
||||
<Empty description="请在左侧选择素材" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-inbox-content">
|
||||
<Typography.Title level={4}>素材详情</Typography.Title>
|
||||
<Card>
|
||||
<Typography.Paragraph>{material.description}</Typography.Paragraph>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="关联时间">{material.associatedDate}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatRelativeTime(material.createdAt)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/web/features/inbox/components/MaterialList.tsx
Normal file
39
src/web/features/inbox/components/MaterialList.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Empty } from "antd";
|
||||
|
||||
import type { Material } from "../types";
|
||||
|
||||
import { MaterialCard } from "./MaterialCard";
|
||||
|
||||
interface MaterialListProps {
|
||||
materials: readonly Material[];
|
||||
onAddClick: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
selectedId: null | string;
|
||||
}
|
||||
|
||||
export function MaterialList({ materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
|
||||
return (
|
||||
<div className="app-inbox-sidebar">
|
||||
<Button block icon={<PlusOutlined />} onClick={onAddClick} type="primary">
|
||||
新增素材
|
||||
</Button>
|
||||
<div className="app-inbox-list">
|
||||
{materials.length === 0 ? (
|
||||
<Empty description="暂无素材" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
materials.map((material) => (
|
||||
<MaterialCard
|
||||
key={material.id}
|
||||
material={material}
|
||||
onDelete={() => onDelete(material.id)}
|
||||
onSelect={() => onSelect(material.id)}
|
||||
selected={material.id === selectedId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/web/features/inbox/index.tsx
Normal file
39
src/web/features/inbox/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Material } from "./types";
|
||||
|
||||
import { AddMaterialModal } from "./components/AddMaterialModal";
|
||||
import { MaterialContent } from "./components/MaterialContent";
|
||||
import { MaterialList } from "./components/MaterialList";
|
||||
|
||||
export function InboxPage() {
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<null | string>(null);
|
||||
|
||||
const selectedMaterial = materials.find((m) => m.id === selectedId) ?? null;
|
||||
|
||||
const handleAdd = (material: Material) => {
|
||||
setMaterials((prev) => [...prev, material].sort((a, b) => b.associatedDate.localeCompare(a.associatedDate)));
|
||||
setSelectedId(material.id);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setMaterials((prev) => prev.filter((m) => m.id !== id));
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app-inbox-page">
|
||||
<MaterialList
|
||||
materials={materials}
|
||||
onAddClick={() => setModalOpen(true)}
|
||||
onDelete={handleDelete}
|
||||
onSelect={setSelectedId}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
<MaterialContent material={selectedMaterial} />
|
||||
<AddMaterialModal onAdd={handleAdd} onOpenChange={setModalOpen} open={modalOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/web/features/inbox/types.ts
Normal file
6
src/web/features/inbox/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface Material {
|
||||
associatedDate: string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
id: string;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { MessageOutlined } from "@ant-design/icons";
|
||||
import { InboxOutlined, MessageOutlined } from "@ant-design/icons";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { MenuItemConfig } from "../../menu";
|
||||
|
||||
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(MessageOutlined), label: "聊天室", path: "", value: "chat" },
|
||||
{ icon: createElement(InboxOutlined), label: "收集箱", path: "inbox", value: "inbox" },
|
||||
] as const;
|
||||
|
||||
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Route, Routes } from "react-router";
|
||||
|
||||
import { ChatPage } from "./features/chat/ChatPage";
|
||||
import { DashboardPage } from "./features/dashboard";
|
||||
import { InboxPage } from "./features/inbox";
|
||||
import { ModelsPage } from "./features/models";
|
||||
import { NotFoundPage } from "./features/not-found";
|
||||
import { ProjectsPage } from "./features/projects";
|
||||
@@ -19,6 +20,7 @@ export function AppRoutes() {
|
||||
<Route element={<WorkbenchProjectGate />} path="/workbench/:projectId">
|
||||
<Route element={<ChatPage />} path="" />
|
||||
<Route element={<ChatPage />} path="chat" />
|
||||
<Route element={<InboxPage />} path="inbox" />
|
||||
</Route>
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
|
||||
@@ -264,3 +264,46 @@ body {
|
||||
.x-markdown-dark .x-md-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.app-inbox-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-inbox-sidebar {
|
||||
display: flex;
|
||||
width: 280px;
|
||||
flex-direction: column;
|
||||
gap: var(--ant-margin-sm);
|
||||
padding: var(--ant-padding-sm);
|
||||
border-right: 1px solid var(--ant-color-border-secondary);
|
||||
background: var(--ant-color-bg-container);
|
||||
}
|
||||
|
||||
.app-inbox-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--ant-margin-xs);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-inbox-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: var(--ant-padding-xl);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-inbox-card-selected {
|
||||
box-shadow: 0 0 0 1px var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.app-inbox-datepicker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
77
tests/web/features/inbox/AddMaterialModal.test.tsx
Normal file
77
tests/web/features/inbox/AddMaterialModal.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
|
||||
import { AddMaterialModal } from "../../../../src/web/features/inbox/components/AddMaterialModal";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("AddMaterialModal", () => {
|
||||
test("打开时渲染表单字段", () => {
|
||||
renderWithProviders(
|
||||
createElement(AddMaterialModal, {
|
||||
onAdd: vi.fn(),
|
||||
onOpenChange: vi.fn(),
|
||||
open: true,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("新增素材")).not.toBeNull();
|
||||
expect(screen.getByText("描述")).not.toBeNull();
|
||||
expect(screen.getByText("关联时间")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("关闭时不渲染", () => {
|
||||
renderWithProviders(
|
||||
createElement(AddMaterialModal, {
|
||||
onAdd: vi.fn(),
|
||||
onOpenChange: vi.fn(),
|
||||
open: false,
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText("新增素材")).toBeNull();
|
||||
});
|
||||
|
||||
test("提交空表单显示必填校验", async () => {
|
||||
renderWithProviders(
|
||||
createElement(AddMaterialModal, {
|
||||
onAdd: vi.fn(),
|
||||
onOpenChange: vi.fn(),
|
||||
open: true,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("确 定"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("请输入描述")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("点击确定触发表单提交", async () => {
|
||||
const onAdd = vi.fn<(material: Material) => void>();
|
||||
renderWithProviders(
|
||||
createElement(AddMaterialModal, {
|
||||
onAdd,
|
||||
onOpenChange: vi.fn(),
|
||||
open: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText("请输入素材描述");
|
||||
fireEvent.change(textarea, { target: { value: "测试描述" } });
|
||||
|
||||
fireEvent.click(screen.getByText("确 定"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const callArgs = onAdd.mock.calls[0];
|
||||
expect(callArgs).toBeDefined();
|
||||
const calledMaterial = callArgs![0];
|
||||
expect(calledMaterial.description).toBe("测试描述");
|
||||
expect(calledMaterial.associatedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(calledMaterial.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
63
tests/web/features/inbox/InboxPage.test.tsx
Normal file
63
tests/web/features/inbox/InboxPage.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { InboxPage } from "../../../../src/web/features/inbox";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
describe("InboxPage", () => {
|
||||
test("初始状态显示空状态提示", () => {
|
||||
renderWithProviders(createElement(InboxPage));
|
||||
expect(screen.getByText("暂无素材")).not.toBeNull();
|
||||
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("新增素材后列表追加且自动选中", async () => {
|
||||
renderWithProviders(createElement(InboxPage));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull();
|
||||
});
|
||||
|
||||
const textarea = screen.getByPlaceholderText("请输入素材描述");
|
||||
fireEvent.change(textarea, { target: { value: "新增的素材" } });
|
||||
|
||||
fireEvent.click(screen.getByText("确 定"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("新增的素材")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByText("素材详情")).not.toBeNull();
|
||||
expect(screen.queryByText("暂无素材")).toBeNull();
|
||||
expect(screen.queryByText("请在左侧选择素材")).toBeNull();
|
||||
});
|
||||
|
||||
test("删除素材后列表更新", async () => {
|
||||
renderWithProviders(createElement(InboxPage));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /新增素材/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("新增素材", { selector: ".ant-modal-title" })).not.toBeNull();
|
||||
});
|
||||
|
||||
const textarea = screen.getByPlaceholderText("请输入素材描述");
|
||||
fireEvent.change(textarea, { target: { value: "待删除的素材" } });
|
||||
|
||||
fireEvent.click(screen.getByText("确 定"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("待删除的素材")).not.toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText("删除"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("暂无素材")).not.toBeNull();
|
||||
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
74
tests/web/features/inbox/MaterialCard.test.tsx
Normal file
74
tests/web/features/inbox/MaterialCard.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
|
||||
import { MaterialCard } from "../../../../src/web/features/inbox/components/MaterialCard";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_MATERIAL: Material = {
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: new Date().toISOString(),
|
||||
description: "测试素材描述",
|
||||
id: "test-id",
|
||||
};
|
||||
|
||||
describe("MaterialCard", () => {
|
||||
test("渲染素材描述和日期信息", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
onDelete: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
selected: false,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("测试素材描述")).not.toBeNull();
|
||||
expect(screen.getByText(/2026-06-03/)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("点击卡片触发 onSelect", () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
onDelete: vi.fn(),
|
||||
onSelect,
|
||||
selected: false,
|
||||
}),
|
||||
);
|
||||
const card = screen.getByText("测试素材描述").closest(".ant-card")!;
|
||||
fireEvent.click(card);
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("点击删除按钮触发 onDelete 且不触发 onSelect", () => {
|
||||
const onDelete = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
onDelete,
|
||||
onSelect,
|
||||
selected: false,
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("删除"));
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("选中状态添加 app-inbox-card-selected 类", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialCard, {
|
||||
material: MOCK_MATERIAL,
|
||||
onDelete: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
selected: true,
|
||||
}),
|
||||
);
|
||||
const card = screen.getByText("测试素材描述").closest(".app-inbox-card-selected");
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
});
|
||||
29
tests/web/features/inbox/MaterialContent.test.tsx
Normal file
29
tests/web/features/inbox/MaterialContent.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
|
||||
import { MaterialContent } from "../../../../src/web/features/inbox/components/MaterialContent";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_MATERIAL: Material = {
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: new Date().toISOString(),
|
||||
description: "详细描述内容",
|
||||
id: "test-id",
|
||||
};
|
||||
|
||||
describe("MaterialContent", () => {
|
||||
test("未选中时显示空状态提示", () => {
|
||||
renderWithProviders(createElement(MaterialContent, { material: null }));
|
||||
expect(screen.getByText("请在左侧选择素材")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("选中时展示素材详情", () => {
|
||||
renderWithProviders(createElement(MaterialContent, { material: MOCK_MATERIAL }));
|
||||
expect(screen.getByText("素材详情")).not.toBeNull();
|
||||
expect(screen.getByText("详细描述内容")).not.toBeNull();
|
||||
expect(screen.getByText("2026-06-03")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
67
tests/web/features/inbox/MaterialList.test.tsx
Normal file
67
tests/web/features/inbox/MaterialList.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { Material } from "../../../../src/web/features/inbox/types";
|
||||
|
||||
import { MaterialList } from "../../../../src/web/features/inbox/components/MaterialList";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
const MOCK_MATERIALS: Material[] = [
|
||||
{
|
||||
associatedDate: "2026-06-03",
|
||||
createdAt: new Date().toISOString(),
|
||||
description: "素材一",
|
||||
id: "id-1",
|
||||
},
|
||||
{
|
||||
associatedDate: "2026-06-02",
|
||||
createdAt: new Date().toISOString(),
|
||||
description: "素材二",
|
||||
id: "id-2",
|
||||
},
|
||||
];
|
||||
|
||||
describe("MaterialList", () => {
|
||||
test("列表为空时显示暂无素材", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
materials: [],
|
||||
onAddClick: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
selectedId: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("暂无素材")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("渲染素材卡片列表", () => {
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
materials: MOCK_MATERIALS,
|
||||
onAddClick: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
selectedId: null,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("素材一")).not.toBeNull();
|
||||
expect(screen.getByText("素材二")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("点击新增素材按钮触发 onAddClick", () => {
|
||||
const onAddClick = vi.fn();
|
||||
renderWithProviders(
|
||||
createElement(MaterialList, {
|
||||
materials: [],
|
||||
onAddClick,
|
||||
onDelete: vi.fn(),
|
||||
onSelect: vi.fn(),
|
||||
selectedId: null,
|
||||
}),
|
||||
);
|
||||
screen.getByText("新增素材").click();
|
||||
expect(onAddClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -120,4 +120,34 @@ describe("Workbench 路由", () => {
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("Workbench 收集箱路由可达", async () => {
|
||||
createMockHandler();
|
||||
|
||||
renderWithProviders(createElement(App), {
|
||||
initialRoute: `/workbench/${MOCK_PROJECT.id}/inbox`,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("新增素材")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
|
||||
test("Workbench 显示收集箱菜单", async () => {
|
||||
createMockHandler();
|
||||
|
||||
renderWithProviders(createElement(App), {
|
||||
initialRoute: `/workbench/${MOCK_PROJECT.id}`,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("收集箱")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user