test: 测试体系全面优化,修复 Windows SQLite EBUSY 和前端产品缺陷
测试基础设施 - 统一 SQLite 测试 DB/临时目录 helper(tests/helpers.ts),支持 Windows EBUSY 重试清理 - 测试库使用 PRAGMA journal_mode=DELETE 避免 WAL 句柄延迟 - 路由 handler 测试改用 createMigratedMemoryTestDatabase 避免 File DB 锁 - SQLite 聚焦 --rerun-each=20 全部通过(720 pass) 后端测试补强 - 新增 tests/server/app.test.ts 真实 startServer 集成测试 - 覆盖 /api/meta、项目 CRUD、错误路径、静态 fallback、安全 header - bootstrap/logger 测试捕获预期输出,消除测试噪音 前端测试补强 - 移除 .ant-* 内部类名依赖,改为角色/文本/导航/请求契约断言 - 项目页补充搜索、Tab 切换、表单、表格操作、错误反馈行为测试 - 新增 hooks(use-theme-preference、use-sidebar-collapsed、use-projects)纯逻辑测试 - 新增 ErrorBoundary 错误展示和刷新按钮测试 - 新增搜索清空行为测试 - 测试 setup 过滤 antd/rc-trigger NaN height warning 产品修复(测试暴露) - 修复 ProjectToolbar 搜索框无法输入(新增 draftKeyword 状态) - 加固 ProjectFormModal 表单字段同步(useEffect 替代不可靠的 afterOpenChange) - 清理 ProjectFormModal 冗余 afterOpenChange 同步逻辑 重构与合规 - ProjectContext 拆分为三文件满足 React Fast Refresh 规则 - use-projects.ts 导出内部 helper 函数供测试验证 - scripts/build.ts 提取纯生成函数供测试使用,修复构建步骤日志编号 - 修复 build 测试覆盖真实生成逻辑 文档同步 - 更新后端/前端/开发文档测试规范、质量门禁和 helper 使用说明
This commit is contained in:
@@ -1,17 +1,9 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
const ProjectContext = createContext<null | Project>(null);
|
||||
import { ProjectContext } from "./ProjectContextValue";
|
||||
|
||||
export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) {
|
||||
return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCurrentProject(): Project {
|
||||
const project = useContext(ProjectContext);
|
||||
if (!project) {
|
||||
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
5
src/web/consoles/workbench/ProjectContextValue.ts
Normal file
5
src/web/consoles/workbench/ProjectContextValue.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
export const ProjectContext = createContext<null | Project>(null);
|
||||
@@ -5,8 +5,9 @@ import { useNavigate } from "react-router";
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
|
||||
import { ProjectProvider, useCurrentProject } from "./ProjectContext";
|
||||
import { ProjectProvider } from "./ProjectContext";
|
||||
import { getWorkbenchMenuItems } from "./routes";
|
||||
import { useCurrentProject } from "./useCurrentProject";
|
||||
|
||||
interface WorkbenchConsoleLayoutProps {
|
||||
project: Project;
|
||||
|
||||
13
src/web/consoles/workbench/useCurrentProject.ts
Normal file
13
src/web/consoles/workbench/useCurrentProject.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
import { ProjectContext } from "./ProjectContextValue";
|
||||
|
||||
export function useCurrentProject(): Project {
|
||||
const project = useContext(ProjectContext);
|
||||
if (!project) {
|
||||
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
@@ -11,6 +11,93 @@ import type {
|
||||
|
||||
const PROJECTS_KEY = ["projects"] as const;
|
||||
|
||||
export async function archiveProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function createProject(data: CreateProjectRequest): Promise<Project> {
|
||||
const response = await fetch("/api/projects", {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function fetchProjectList(params: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ProjectStatus;
|
||||
}): Promise<ProjectListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
||||
if (params.status) searchParams.set("status", params.status);
|
||||
const qs = searchParams.toString();
|
||||
const url = `/api/projects${qs ? `?${qs}` : ""}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<ProjectListResponse>;
|
||||
}
|
||||
|
||||
export async function restoreProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`, {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
export function useArchiveProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -75,90 +162,3 @@ export function useUpdateProject() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
async function createProject(data: CreateProjectRequest): Promise<Project> {
|
||||
const response = await fetch("/api/projects", {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
async function deleteProject(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
async function fetchProjectList(params: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ProjectStatus;
|
||||
}): Promise<ProjectListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
||||
if (params.status) searchParams.set("status", params.status);
|
||||
const qs = searchParams.toString();
|
||||
const url = `/api/projects${qs ? `?${qs}` : ""}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<ProjectListResponse>;
|
||||
}
|
||||
|
||||
async function restoreProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`, {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App as AntApp, Form, Input, Modal } from "antd";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../../../../shared/api";
|
||||
|
||||
@@ -29,6 +30,15 @@ export function ProjectFormModal({
|
||||
const { message } = AntApp.useApp();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editingProject) {
|
||||
form.setFieldsValue({ description: editingProject.description, name: editingProject.name });
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [editingProject, form, open]);
|
||||
|
||||
const handleFinish = async (values: FormValues) => {
|
||||
try {
|
||||
if (editingProject) {
|
||||
@@ -52,15 +62,6 @@ export function ProjectFormModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) {
|
||||
if (editingProject) {
|
||||
form.setFieldsValue({ description: editingProject.description, name: editingProject.name });
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}}
|
||||
confirmLoading={submitting}
|
||||
destroyOnHidden
|
||||
okText="确定"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Flex, Input, Tabs } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ProjectStatus } from "../../../../shared/api";
|
||||
|
||||
@@ -25,6 +26,8 @@ export function ProjectToolbar({
|
||||
onTabChange,
|
||||
openCreateDialog,
|
||||
}: ProjectToolbarProps) {
|
||||
const [draftKeyword, setDraftKeyword] = useState(keyword);
|
||||
|
||||
return (
|
||||
<Flex align="center" gap="var(--ant-margin-lg)" justify="space-between" wrap="wrap">
|
||||
<Tabs activeKey={activeTab} items={STATUS_TAB_ITEMS} onChange={onTabChange} />
|
||||
@@ -32,10 +35,14 @@ export function ProjectToolbar({
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton="搜索"
|
||||
onClear={onSearchClear}
|
||||
onSearch={onSearch}
|
||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
||||
onClear={() => {
|
||||
setDraftKeyword("");
|
||||
onSearchClear();
|
||||
}}
|
||||
onSearch={(value) => onSearch(value)}
|
||||
placeholder="搜索项目名称或描述"
|
||||
value={keyword}
|
||||
value={draftKeyword}
|
||||
/>
|
||||
{activeTab === "active" && (
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, Descriptions, Space, Typography } from "antd";
|
||||
|
||||
import { useCurrentProject } from "../../consoles/workbench/ProjectContext";
|
||||
import { useCurrentProject } from "../../consoles/workbench/useCurrentProject";
|
||||
|
||||
export function WorkbenchOverviewPage() {
|
||||
const project = useCurrentProject();
|
||||
|
||||
Reference in New Issue
Block a user