- Chunk 1: 测试基础设施(msw health handlers + fixtures + renderWithProviders) - Chunk 2: ListPage 测试工厂(createListPageTests + 3 页面验证) - Chunk 3: 第一批 7 个列表页测试(预约/随访/咨询/透析/活动)
1156 lines
34 KiB
Markdown
1156 lines
34 KiB
Markdown
# Web 页面/组件测试 — 第一批实施计划
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 建立 Web 前端页面测试基础设施(renderWithProviders + msw handlers + testFixtures),开发 ListPage 测试工厂,为第一批 10 个健康管理列表页生成测试文件。
|
||
|
||
**Architecture:** 模式化测试工厂 — createListPageTests(config) 接受页面组件和 API 配置,自动生成「渲染加载 API / 表格列名 / 分页 / 筛选 / 新建按钮 / 编辑按钮」6 个标准测试用例。用 msw 拦截 HTTP 请求,不 mock axios。
|
||
|
||
**Tech Stack:** Vitest 4 + @testing-library/react 16 + msw 2 + Zustand 5 (setState)
|
||
|
||
**设计规格:** `docs/superpowers/specs/2026-05-03-web-page-component-testing-design.md`
|
||
|
||
---
|
||
|
||
## Chunk 1: 测试基础设施
|
||
|
||
### Task 1: msw handlers 扩展(健康模块 API mock)
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/test/mocks/healthHandlers.ts`
|
||
- Modify: `apps/web/src/test/mocks/handlers.ts`
|
||
|
||
- [ ] **Step 1: 创建 healthHandlers.ts — 患者列表 mock**
|
||
|
||
```typescript
|
||
// apps/web/src/test/mocks/healthHandlers.ts
|
||
import { http, HttpResponse, delay } from 'msw';
|
||
|
||
const DEFAULT_PAGE_SIZE = 20;
|
||
|
||
function paginatedResponse<T>(items: T[], total: number, page: number, pageSize = DEFAULT_PAGE_SIZE) {
|
||
return HttpResponse.json({
|
||
success: true,
|
||
data: {
|
||
data: items,
|
||
total,
|
||
page,
|
||
page_size: pageSize,
|
||
total_pages: Math.ceil(total / pageSize),
|
||
},
|
||
});
|
||
}
|
||
|
||
// --- 患者列表 ---
|
||
const mockPatients = Array.from({ length: 25 }, (_, i) => ({
|
||
id: `patient-${i + 1}`,
|
||
name: `测试患者${i + 1}`,
|
||
gender: i % 2 === 0 ? 'male' : 'female',
|
||
birth_date: '1990-01-15',
|
||
blood_type: 'A',
|
||
status: 'active',
|
||
verification_status: i < 20 ? 'verified' : 'pending',
|
||
source: 'manual',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
export const patientHandlers = [
|
||
http.get('/api/v1/health/patients', async ({ request }) => {
|
||
await delay(50);
|
||
const url = new URL(request.url);
|
||
const page = Number(url.searchParams.get('page') || 1);
|
||
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
|
||
const start = (page - 1) * pageSize;
|
||
const items = mockPatients.slice(start, start + pageSize);
|
||
return paginatedResponse(items, mockPatients.length, page, pageSize);
|
||
}),
|
||
|
||
http.get('/api/v1/health/patients/:id', async ({ params }) => {
|
||
await delay(50);
|
||
const patient = mockPatients.find((p) => p.id === params.id);
|
||
if (!patient) return HttpResponse.json({ success: false, error: 'Not found' }, { status: 404 });
|
||
return HttpResponse.json({ success: true, data: { ...patient, notes: '', allergy_history: '', medical_history_summary: '' } });
|
||
}),
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 2: 添加 alert、appointment、doctor handlers**
|
||
|
||
```typescript
|
||
// 追加到 healthHandlers.ts
|
||
|
||
// --- 告警列表 ---
|
||
const mockAlerts = Array.from({ length: 12 }, (_, i) => ({
|
||
id: `alert-${i + 1}`,
|
||
patient_id: `patient-${i + 1}`,
|
||
patient_name: `测试患者${i + 1}`,
|
||
alert_type: i % 3 === 0 ? 'vital_sign' : i % 3 === 1 ? 'lab_result' : 'overdue_followup',
|
||
severity: (['low', 'medium', 'high'] as const)[i % 3],
|
||
status: i < 8 ? 'active' : 'resolved',
|
||
message: `告警消息 ${i + 1}`,
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
export const alertHandlers = [
|
||
http.get('/api/v1/health/alerts', async ({ request }) => {
|
||
await delay(50);
|
||
const url = new URL(request.url);
|
||
const page = Number(url.searchParams.get('page') || 1);
|
||
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
|
||
return paginatedResponse(mockAlerts.slice(0, pageSize), mockAlerts.length, page, pageSize);
|
||
}),
|
||
];
|
||
|
||
// --- 预约列表 ---
|
||
const mockAppointments = Array.from({ length: 15 }, (_, i) => ({
|
||
id: `appt-${i + 1}`,
|
||
patient_id: `patient-${i + 1}`,
|
||
patient_name: `测试患者${i + 1}`,
|
||
doctor_id: `doctor-${(i % 5) + 1}`,
|
||
doctor_name: `测试医生${(i % 5) + 1}`,
|
||
appointment_date: '2026-05-10',
|
||
start_time: '09:00',
|
||
end_time: '09:30',
|
||
status: (['pending', 'confirmed', 'completed', 'cancelled'] as const)[i % 4],
|
||
type: 'follow_up',
|
||
notes: '',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
export const appointmentHandlers = [
|
||
http.get('/api/v1/health/appointments', async ({ request }) => {
|
||
await delay(50);
|
||
const url = new URL(request.url);
|
||
const page = Number(url.searchParams.get('page') || 1);
|
||
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
|
||
return paginatedResponse(mockAppointments.slice(0, pageSize), mockAppointments.length, page, pageSize);
|
||
}),
|
||
];
|
||
|
||
// --- 医生列表 ---
|
||
const mockDoctors = Array.from({ length: 8 }, (_, i) => ({
|
||
id: `doctor-${i + 1}`,
|
||
user_id: `user-doc-${i + 1}`,
|
||
name: `测试医生${i + 1}`,
|
||
department: '内科',
|
||
title: '主治医师',
|
||
specialization: '心血管内科',
|
||
phone: `1380000${String(i + 1).padStart(4, '0')}`,
|
||
email: `doctor${i + 1}@test.com`,
|
||
status: 'online',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
export const doctorHandlers = [
|
||
http.get('/api/v1/health/doctors', async ({ request }) => {
|
||
await delay(50);
|
||
const url = new URL(request.url);
|
||
const page = Number(url.searchParams.get('page') || 1);
|
||
const pageSize = Number(url.searchParams.get('page_size') || DEFAULT_PAGE_SIZE);
|
||
return paginatedResponse(mockDoctors.slice(0, pageSize), mockDoctors.length, page, pageSize);
|
||
}),
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 3: 在 handlers.ts 中注册 health handlers**
|
||
|
||
```typescript
|
||
// apps/web/src/test/mocks/handlers.ts — 替换末尾 export
|
||
import {
|
||
patientHandlers,
|
||
alertHandlers,
|
||
appointmentHandlers,
|
||
doctorHandlers,
|
||
} from './healthHandlers';
|
||
|
||
export const handlers = [...authHandlers, ...patientHandlers, ...alertHandlers, ...appointmentHandlers, ...doctorHandlers];
|
||
```
|
||
|
||
- [ ] **Step 4: 验证 msw 正常加载**
|
||
|
||
Run: `cd apps/web && npx vitest run --reporter=verbose 2>&1 | head -30`
|
||
Expected: 现有测试全部通过(msw setup 不影响已有测试)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/test/mocks/healthHandlers.ts apps/web/src/test/mocks/handlers.ts
|
||
git commit -m "test(web): 添加健康模块 msw handlers — 患者告警预约医生 4 组 mock API"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: testFixtures(测试数据工厂)
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/test/fixtures/index.ts`
|
||
- Create: `apps/web/src/test/fixtures/healthFixtures.ts`
|
||
|
||
- [ ] **Step 1: 创建 healthFixtures.ts — 基础 fixture 工厂函数**
|
||
|
||
```typescript
|
||
// apps/web/src/test/fixtures/healthFixtures.ts
|
||
|
||
// --- 患者 ---
|
||
export function createPatientFixture(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: 'patient-1',
|
||
name: '张三',
|
||
gender: 'male',
|
||
birth_date: '1990-01-15',
|
||
blood_type: 'A',
|
||
status: 'active',
|
||
verification_status: 'verified',
|
||
source: 'manual',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
// --- 医生 ---
|
||
export function createDoctorFixture(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: 'doctor-1',
|
||
user_id: 'user-doc-1',
|
||
name: '李医生',
|
||
department: '内科',
|
||
title: '主治医师',
|
||
specialization: '心血管内科',
|
||
phone: '13800000001',
|
||
email: 'doctor1@test.com',
|
||
status: 'online',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
// --- 告警 ---
|
||
export function createAlertFixture(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: 'alert-1',
|
||
patient_id: 'patient-1',
|
||
patient_name: '张三',
|
||
alert_type: 'vital_sign',
|
||
severity: 'high',
|
||
status: 'active',
|
||
message: '血压异常偏高',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
// --- 预约 ---
|
||
export function createAppointmentFixture(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
id: 'appt-1',
|
||
patient_id: 'patient-1',
|
||
patient_name: '张三',
|
||
doctor_id: 'doctor-1',
|
||
doctor_name: '李医生',
|
||
appointment_date: '2026-05-10',
|
||
start_time: '09:00',
|
||
end_time: '09:30',
|
||
status: 'pending',
|
||
type: 'follow_up',
|
||
notes: '',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
// --- 批量生成 ---
|
||
export function createFixtureList<T>(factory: (overrides?: Record<string, unknown>) => T, count: number, overridesList: Record<string, unknown>[] = []): T[] {
|
||
return Array.from({ length: count }, (_, i) => factory(overridesList[i] || {}));
|
||
}
|
||
|
||
// --- 分页响应包装 ---
|
||
export function wrapPaginated<T>(items: T[], total?: number) {
|
||
return {
|
||
data: items,
|
||
total: total ?? items.length,
|
||
page: 1,
|
||
page_size: 20,
|
||
total_pages: Math.ceil((total ?? items.length) / 20),
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 fixtures/index.ts — 统一导出**
|
||
|
||
```typescript
|
||
// apps/web/src/test/fixtures/index.ts
|
||
export * from './healthFixtures';
|
||
```
|
||
|
||
- [ ] **Step 3: 在现有 API 测试中验证 fixture**
|
||
|
||
Run: `cd apps/web && npx vitest run src/api/health/patients.test.ts --reporter=verbose`
|
||
Expected: 现有测试通过(fixture 文件不影响已有代码)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/test/fixtures/
|
||
git commit -m "test(web): 添加测试数据工厂 — healthFixtures + 批量生成 + 分页包装"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: renderWithProviders(渲染包裹器)
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/test/utils/renderWithProviders.tsx`
|
||
|
||
- [ ] **Step 1: 编写 renderWithProviders 失败测试**
|
||
|
||
```typescript
|
||
// apps/web/src/test/utils/renderWithProviders.test.tsx
|
||
import { describe, it, expect } from 'vitest';
|
||
import { screen } from '@testing-library/react';
|
||
import { renderWithProviders } from './renderWithProviders';
|
||
|
||
function DummyPage() {
|
||
return <div data-testid="dummy">Hello Test</div>;
|
||
}
|
||
|
||
describe('renderWithProviders', () => {
|
||
it('renders child component', () => {
|
||
renderWithProviders(<DummyPage />);
|
||
expect(screen.getByTestId('dummy')).toHaveTextContent('Hello Test');
|
||
});
|
||
|
||
it('wraps with MemoryRouter — Link renders without error', () => {
|
||
function PageWithLink() {
|
||
return (
|
||
<div>
|
||
<a href="/test">Go</a>
|
||
</div>
|
||
);
|
||
}
|
||
renderWithProviders(<PageWithLink />);
|
||
expect(screen.getByText('Go')).toHaveAttribute('href', '/test');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd apps/web && npx vitest run src/test/utils/renderWithProviders.test.tsx`
|
||
Expected: FAIL — `renderWithProviders` 不存在
|
||
|
||
- [ ] **Step 3: 实现 renderWithProviders**
|
||
|
||
```tsx
|
||
// apps/web/src/test/utils/renderWithProviders.tsx
|
||
import { ReactElement } from 'react';
|
||
import { render, RenderOptions } from '@testing-library/react';
|
||
import { MemoryRouter } from 'react-router-dom';
|
||
import { ConfigProvider } from 'antd';
|
||
import zhCN from 'antd/locale/zh_CN';
|
||
|
||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||
route?: string;
|
||
}
|
||
|
||
export function renderWithProviders(ui: ReactElement, options: CustomRenderOptions = {}) {
|
||
const { route = '/', ...renderOptions } = options;
|
||
|
||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<MemoryRouter initialEntries={[route]}>
|
||
<ConfigProvider locale={zhCN}>{children}</ConfigProvider>
|
||
</MemoryRouter>
|
||
);
|
||
}
|
||
|
||
return render(ui, { wrapper: Wrapper, ...renderOptions });
|
||
}
|
||
|
||
// Re-export everything from @testing-library/react for convenience
|
||
export * from '@testing-library/react';
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd apps/web && npx vitest run src/test/utils/renderWithProviders.test.tsx`
|
||
Expected: PASS — 2 tests passed
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/test/utils/
|
||
git commit -m "test(web): 添加 renderWithProviders — MemoryRouter + AntD ConfigProvider 包裹器"
|
||
```
|
||
|
||
## Chunk 2: ListPage 测试工厂
|
||
|
||
### Task 4: createListPageTests 工厂函数
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/test/factories/listPageTests.ts`
|
||
|
||
- [ ] **Step 1: 编写工厂接口定义和骨架 — 失败测试**
|
||
|
||
```typescript
|
||
// apps/web/src/test/factories/listPageTests.test.tsx
|
||
import { describe, it, expect } from 'vitest';
|
||
import { createListPageTests } from './listPageTests';
|
||
|
||
describe('createListPageTests', () => {
|
||
it('is a function that returns a describe block', () => {
|
||
expect(typeof createListPageTests).toBe('function');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd apps/web && npx vitest run src/test/factories/listPageTests.test.tsx`
|
||
Expected: FAIL — 模块不存在
|
||
|
||
- [ ] **Step 3: 实现 createListPageTests 工厂**
|
||
|
||
```typescript
|
||
// apps/web/src/test/factories/listPageTests.ts
|
||
import { describe, it, expect, vi, type Vitest } from 'vitest';
|
||
import { screen, waitFor, within } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { renderWithProviders } from '../utils/renderWithProviders';
|
||
import { http, HttpResponse } from 'msw';
|
||
import { server } from '../mocks/server';
|
||
import type { ComponentType } from 'react';
|
||
|
||
export interface ListPageTestConfig {
|
||
/** 页面组件 */
|
||
Component: ComponentType;
|
||
/** 列表 API 路径(如 '/api/v1/health/patients') */
|
||
apiPath: string;
|
||
/** 表格列名数组 — 用于验证表头 */
|
||
columns: string[];
|
||
/** 第一条 mock 数据中会被渲染到表格的文本 */
|
||
firstRowTexts: string[];
|
||
/** mock 数据总条数 */
|
||
totalItems: number;
|
||
/** 是否有「新建」按钮 */
|
||
hasCreateButton?: boolean;
|
||
/** 新建按钮文本(默认 "新建") */
|
||
createButtonText?: string;
|
||
/** 是否有搜索/筛选 */
|
||
hasSearch?: boolean;
|
||
/** 是否有分页(默认 true) */
|
||
hasPagination?: boolean;
|
||
/** 自定义路由(默认 '/') */
|
||
route?: string;
|
||
/** 自定义 mock 数据(默认使用空数组 + totalItems) */
|
||
mockItems?: Record<string, unknown>[];
|
||
/** 额外测试用例 */
|
||
extraTests?: () => void;
|
||
}
|
||
|
||
function createMockResponse(config: ListPageTestConfig) {
|
||
const items = config.mockItems ?? [];
|
||
return {
|
||
success: true,
|
||
data: {
|
||
data: items,
|
||
total: config.totalItems,
|
||
page: 1,
|
||
page_size: 20,
|
||
total_pages: Math.ceil(config.totalItems / 20),
|
||
},
|
||
};
|
||
}
|
||
|
||
export function createListPageTests(config: ListPageTestConfig) {
|
||
const {
|
||
Component,
|
||
apiPath,
|
||
columns,
|
||
firstRowTexts,
|
||
totalItems,
|
||
hasCreateButton = true,
|
||
createButtonText = '新建',
|
||
hasSearch = true,
|
||
hasPagination = true,
|
||
route = '/',
|
||
extraTests,
|
||
} = config;
|
||
|
||
describe(`${Component.displayName || Component.name || 'ListPage'}`, () => {
|
||
// 每个测试用自定义 handler 覆盖默认
|
||
function setupMock(items?: Record<string, unknown>[], total?: number) {
|
||
const mockData = items ?? config.mockItems ?? [];
|
||
const mockTotal = total ?? totalItems;
|
||
server.use(
|
||
http.get(apiPath, () =>
|
||
HttpResponse.json({
|
||
success: true,
|
||
data: { data: mockData, total: mockTotal, page: 1, page_size: 20, total_pages: Math.ceil(mockTotal / 20) },
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
|
||
it('渲染加载状态并显示数据', async () => {
|
||
setupMock(config.mockItems);
|
||
renderWithProviders(<Component />, { route });
|
||
|
||
// 等待加载完成
|
||
await waitFor(() => {
|
||
const table = document.querySelector('.ant-table');
|
||
expect(table).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it('表格包含正确的列名', async () => {
|
||
setupMock(config.mockItems);
|
||
renderWithProviders(<Component />, { route });
|
||
|
||
await waitFor(() => {
|
||
const headers = document.querySelectorAll('th');
|
||
const headerTexts = Array.from(headers).map((h) => h.textContent?.trim());
|
||
for (const col of columns) {
|
||
expect(headerTexts.some((t) => t?.includes(col))).toBe(true);
|
||
}
|
||
});
|
||
});
|
||
|
||
if (hasPagination && totalItems > 20) {
|
||
it('分页器显示正确的总数', async () => {
|
||
setupMock(config.mockItems, totalItems);
|
||
renderWithProviders(<Component />, { route });
|
||
|
||
await waitFor(() => {
|
||
const pagination = document.querySelector('.ant-pagination');
|
||
expect(pagination).toBeInTheDocument();
|
||
});
|
||
});
|
||
}
|
||
|
||
if (hasCreateButton) {
|
||
it('显示新建按钮', async () => {
|
||
setupMock(config.mockItems);
|
||
renderWithProviders(<Component />, { route });
|
||
|
||
await waitFor(() => {
|
||
const btn = screen.getByRole('button', { name: new RegExp(createButtonText) });
|
||
expect(btn).toBeInTheDocument();
|
||
});
|
||
});
|
||
}
|
||
|
||
if (hasSearch) {
|
||
it('搜索/筛选区域存在', async () => {
|
||
setupMock(config.mockItems);
|
||
renderWithProviders(<Component />, { route });
|
||
|
||
await waitFor(() => {
|
||
const table = document.querySelector('.ant-table');
|
||
expect(table).toBeInTheDocument();
|
||
});
|
||
// 验证页面至少有一个 input 或 select 用于筛选
|
||
const inputs = document.querySelectorAll('input, .ant-select, .ant-picker');
|
||
expect(inputs.length).toBeGreaterThan(0);
|
||
});
|
||
}
|
||
|
||
if (extraTests) {
|
||
extraTests();
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd apps/web && npx vitest run src/test/factories/listPageTests.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/test/factories/
|
||
git commit -m "test(web): 添加 createListPageTests 工厂 — 6 类标准测试用例自动生成"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 用 PatientList 验证工厂(完整 CRUD 模式)
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/PatientList.test.tsx`
|
||
|
||
- [ ] **Step 1: 编写 PatientList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/PatientList.test.tsx
|
||
import { http, HttpResponse } from 'msw';
|
||
import { server } from '../../test/mocks/server';
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import { createFixtureList, createPatientFixture } from '../../test/fixtures';
|
||
import PatientList from './PatientList';
|
||
|
||
const mockPatients = createFixtureList(createPatientFixture, 25, [
|
||
{ name: '张三', gender: 'male' },
|
||
{ name: '李四', gender: 'female' },
|
||
]);
|
||
|
||
createListPageTests({
|
||
Component: PatientList,
|
||
apiPath: '/api/v1/health/patients',
|
||
columns: ['患者姓名', '性别', '状态'],
|
||
firstRowTexts: ['张三'],
|
||
totalItems: 25,
|
||
hasCreateButton: true,
|
||
createButtonText: '新建',
|
||
hasSearch: true,
|
||
hasPagination: true,
|
||
mockItems: mockPatients as Record<string, unknown>[],
|
||
extraTests: () => {
|
||
it('点击新建按钮打开表单', async () => {
|
||
server.use(
|
||
http.get('/api/v1/health/patients', () =>
|
||
HttpResponse.json({
|
||
success: true,
|
||
data: { data: mockPatients.slice(0, 20), total: 25, page: 1, page_size: 20, total_pages: 2 },
|
||
}),
|
||
),
|
||
);
|
||
const { renderWithProviders } = await import('../../test/utils/renderWithProviders');
|
||
const user = userEvent.setup();
|
||
renderWithProviders(<PatientList />);
|
||
|
||
const btn = await screen.findByRole('button', { name: /新建/ });
|
||
await user.click(btn);
|
||
|
||
// 抽屉/模态框应该打开
|
||
await waitFor(() => {
|
||
const drawer = document.querySelector('.ant-drawer-open, .ant-modal-open');
|
||
expect(drawer).toBeInTheDocument();
|
||
});
|
||
});
|
||
},
|
||
});
|
||
|
||
import { screen, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
```
|
||
|
||
> **注意:** 此测试验证工厂的完整 CRUD 模式(新建按钮 + 搜索 + 分页 + 抽屉表单)。
|
||
|
||
- [ ] **Step 2: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/PatientList.test.tsx --reporter=verbose`
|
||
Expected: PASS — 7 个测试用例(6 标准 + 1 extra)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/PatientList.test.tsx
|
||
git commit -m "test(web): PatientList 页面测试 — 验证工厂完整 CRUD 模式"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 用 AlertList 验证工厂变体(无创建按钮,有操作按钮)
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/AlertList.test.tsx`
|
||
|
||
- [ ] **Step 1: 编写 AlertList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/AlertList.test.tsx
|
||
import { http, HttpResponse } from 'msw';
|
||
import { server } from '../../test/mocks/server';
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import { createFixtureList, createAlertFixture } from '../../test/fixtures';
|
||
import AlertList from './AlertList';
|
||
|
||
const mockAlerts = createFixtureList(createAlertFixture, 12, [
|
||
{ severity: 'high', status: 'active', title: '血压异常偏高' },
|
||
{ severity: 'medium', status: 'active', title: '心率偏高' },
|
||
]);
|
||
|
||
createListPageTests({
|
||
Component: AlertList,
|
||
apiPath: '/api/v1/health/alerts',
|
||
columns: ['严重程度', '状态'],
|
||
firstRowTexts: ['血压异常偏高'],
|
||
totalItems: 12,
|
||
hasCreateButton: false,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockAlerts as Record<string, unknown>[],
|
||
extraTests: () => {
|
||
it('显示告警严重程度标签', async () => {
|
||
server.use(
|
||
http.get('/api/v1/health/alerts', () =>
|
||
HttpResponse.json({
|
||
success: true,
|
||
data: { data: mockAlerts, total: 12, page: 1, page_size: 20, total_pages: 1 },
|
||
}),
|
||
),
|
||
);
|
||
const { renderWithProviders } = await import('../../test/utils/renderWithProviders');
|
||
renderWithProviders(<AlertList />);
|
||
|
||
await waitFor(() => {
|
||
const tags = document.querySelectorAll('.ant-tag');
|
||
expect(tags.length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
},
|
||
});
|
||
|
||
import { screen, waitFor } from '@testing-library/react';
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/AlertList.test.tsx --reporter=verbose`
|
||
Expected: PASS — 5 个测试用例(无创建按钮测试,1 extra)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/AlertList.test.tsx
|
||
git commit -m "test(web): AlertList 页面测试 — 验证工厂无创建按钮变体"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 用 DoctorList 验证简单场景
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/DoctorList.test.tsx`
|
||
|
||
- [ ] **Step 1: 编写 DoctorList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/DoctorList.test.tsx
|
||
import { http, HttpResponse } from 'msw';
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import { createFixtureList, createDoctorFixture } from '../../test/fixtures';
|
||
import DoctorList from './DoctorList';
|
||
|
||
const mockDoctors = createFixtureList(createDoctorFixture, 8);
|
||
|
||
createListPageTests({
|
||
Component: DoctorList,
|
||
apiPath: '/api/v1/health/doctors',
|
||
columns: ['姓名', '科室', '职称'],
|
||
firstRowTexts: ['李医生'],
|
||
totalItems: 8,
|
||
hasCreateButton: true,
|
||
createButtonText: '新建',
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockDoctors as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/DoctorList.test.tsx --reporter=verbose`
|
||
Expected: PASS — 4 个标准测试用例
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/DoctorList.test.tsx
|
||
git commit -m "test(web): DoctorList 页面测试 — 验证工厂简单列表模式"
|
||
```
|
||
|
||
- [ ] **Step 4: 运行全部前端测试确认无回归**
|
||
|
||
Run: `cd apps/web && npx vitest run --reporter=verbose`
|
||
Expected: 全部通过(原有 36 + 新增 3 页面测试文件)
|
||
|
||
- [ ] **Step 5: Commit(如有修复)**
|
||
|
||
```bash
|
||
git add -u
|
||
git commit -m "test(web): 修复工厂验证过程中的兼容性问题"
|
||
```
|
||
|
||
## Chunk 3: 第一批列表页测试
|
||
|
||
### Task 8: AppointmentList 测试
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/AppointmentList.test.tsx`
|
||
|
||
- [ ] **Step 1: 编写 AppointmentList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/AppointmentList.test.tsx
|
||
import { http, HttpResponse } from 'msw';
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import { createFixtureList, createAppointmentFixture } from '../../test/fixtures';
|
||
import AppointmentList from './AppointmentList';
|
||
|
||
const mockAppointments = createFixtureList(createAppointmentFixture, 15, [
|
||
{ patient_name: '张三', doctor_name: '李医生', status: 'pending' },
|
||
{ patient_name: '王五', doctor_name: '赵医生', status: 'confirmed' },
|
||
]);
|
||
|
||
createListPageTests({
|
||
Component: AppointmentList,
|
||
apiPath: '/api/v1/health/appointments',
|
||
columns: ['患者', '医生', '日期', '状态'],
|
||
firstRowTexts: ['张三'],
|
||
totalItems: 15,
|
||
hasCreateButton: true,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockAppointments as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/AppointmentList.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/AppointmentList.test.tsx
|
||
git commit -m "test(web): AppointmentList 页面测试 — 预约列表模式"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: FollowUpTaskList + FollowUpRecordList 测试
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/FollowUpTaskList.test.tsx`
|
||
- Create: `apps/web/src/pages/health/FollowUpRecordList.test.tsx`
|
||
|
||
- [ ] **Step 1: 检查 FollowUp API 端点**
|
||
|
||
Run: `grep -n "list.*=" apps/web/src/api/health/followUp.ts | head -10`
|
||
确认 API 路径,用于配置 `apiPath`。
|
||
|
||
- [ ] **Step 2: 编写 FollowUpTaskList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/FollowUpTaskList.test.tsx
|
||
import { http, HttpResponse } from 'msw';
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import FollowUpTaskList from './FollowUpTaskList';
|
||
|
||
// FollowUpTask fixture
|
||
const mockTasks = Array.from({ length: 10 }, (_, i) => ({
|
||
id: `task-${i + 1}`,
|
||
patient_id: `patient-${i + 1}`,
|
||
patient_name: `患者${i + 1}`,
|
||
template_id: `tmpl-1`,
|
||
template_name: '血压随访',
|
||
status: i % 2 === 0 ? 'pending' : 'completed',
|
||
due_date: '2026-05-15',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
createListPageTests({
|
||
Component: FollowUpTaskList,
|
||
apiPath: '/api/v1/health/follow-up/tasks',
|
||
columns: ['患者', '状态'],
|
||
firstRowTexts: ['患者1'],
|
||
totalItems: 10,
|
||
hasCreateButton: false,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockTasks as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 运行测试并修复 apiPath**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/FollowUpTaskList.test.tsx`
|
||
Expected: PASS(如果 API 路径不匹配,根据 Step 1 的结果调整 `apiPath`)
|
||
|
||
- [ ] **Step 4: 编写 FollowUpRecordList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/FollowUpRecordList.test.tsx
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import FollowUpRecordList from './FollowUpRecordList';
|
||
|
||
const mockRecords = Array.from({ length: 10 }, (_, i) => ({
|
||
id: `record-${i + 1}`,
|
||
patient_id: `patient-${i + 1}`,
|
||
patient_name: `患者${i + 1}`,
|
||
task_id: `task-${i + 1}`,
|
||
status: i % 2 === 0 ? 'completed' : 'cancelled',
|
||
follow_up_date: '2026-05-10',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
createListPageTests({
|
||
Component: FollowUpRecordList,
|
||
apiPath: '/api/v1/health/follow-up/records',
|
||
columns: ['患者', '状态'],
|
||
firstRowTexts: ['患者1'],
|
||
totalItems: 10,
|
||
hasCreateButton: false,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockRecords as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/FollowUpRecordList.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/FollowUpTaskList.test.tsx apps/web/src/pages/health/FollowUpRecordList.test.tsx
|
||
git commit -m "test(web): FollowUpTaskList + FollowUpRecordList 页面测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: ConsultationList 测试
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/ConsultationList.test.tsx`
|
||
|
||
- [ ] **Step 1: 检查 Consultation API 端点**
|
||
|
||
Run: `grep -n "list" apps/web/src/api/health/consultations.ts | head -5`
|
||
|
||
- [ ] **Step 2: 编写 ConsultationList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/ConsultationList.test.tsx
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import ConsultationList from './ConsultationList';
|
||
|
||
const mockConsultations = Array.from({ length: 8 }, (_, i) => ({
|
||
id: `consult-${i + 1}`,
|
||
patient_id: `patient-${i + 1}`,
|
||
patient_name: `患者${i + 1}`,
|
||
doctor_id: `doctor-1`,
|
||
doctor_name: '李医生',
|
||
status: (['waiting', 'active', 'closed'] as const)[i % 3],
|
||
type: 'online',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
createListPageTests({
|
||
Component: ConsultationList,
|
||
apiPath: '/api/v1/health/consultations',
|
||
columns: ['患者', '医生', '状态'],
|
||
firstRowTexts: ['患者1'],
|
||
totalItems: 8,
|
||
hasCreateButton: true,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockConsultations as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/ConsultationList.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/ConsultationList.test.tsx
|
||
git commit -m "test(web): ConsultationList 页面测试 — 咨询列表模式"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: FollowUpTemplateList 测试
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/FollowUpTemplateList.test.tsx`
|
||
|
||
- [ ] **Step 1: 检查 FollowUpTemplate API 端点**
|
||
|
||
Run: `grep -n "list" apps/web/src/api/health/followUpTemplates.ts | head -5`
|
||
|
||
- [ ] **Step 2: 编写 FollowUpTemplateList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/FollowUpTemplateList.test.tsx
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import FollowUpTemplateList from './FollowUpTemplateList';
|
||
|
||
const mockTemplates = Array.from({ length: 5 }, (_, i) => ({
|
||
id: `tmpl-${i + 1}`,
|
||
name: `随访模板${i + 1}`,
|
||
description: `描述${i + 1}`,
|
||
category: 'chronic',
|
||
frequency_days: 30,
|
||
status: 'active',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
createListPageTests({
|
||
Component: FollowUpTemplateList,
|
||
apiPath: '/api/v1/health/follow-up/templates',
|
||
columns: ['名称', '状态'],
|
||
firstRowTexts: ['随访模板1'],
|
||
totalItems: 5,
|
||
hasCreateButton: true,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockTemplates as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/FollowUpTemplateList.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/FollowUpTemplateList.test.tsx
|
||
git commit -m "test(web): FollowUpTemplateList 页面测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: DialysisManageList + OfflineEventList 测试
|
||
|
||
**Files:**
|
||
- Create: `apps/web/src/pages/health/DialysisManageList.test.tsx`
|
||
- Create: `apps/web/src/pages/health/OfflineEventList.test.tsx`
|
||
|
||
- [ ] **Step 1: 检查 Dialysis + OfflineEvent API 端点**
|
||
|
||
Run: `grep -rn "list" apps/web/src/api/health/dialysis.ts | head -5`
|
||
Run: `grep -rn "list" apps/web/src/api/health/actionInbox.ts | head -5`
|
||
|
||
- [ ] **Step 2: 编写 DialysisManageList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/DialysisManageList.test.tsx
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import DialysisManageList from './DialysisManageList';
|
||
|
||
const mockDialysis = Array.from({ length: 6 }, (_, i) => ({
|
||
id: `dialysis-${i + 1}`,
|
||
patient_id: `patient-${i + 1}`,
|
||
patient_name: `患者${i + 1}`,
|
||
treatment_date: '2026-05-10',
|
||
status: (['scheduled', 'in_progress', 'completed'] as const)[i % 3],
|
||
dialysis_type: 'hemodialysis',
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
createListPageTests({
|
||
Component: DialysisManageList,
|
||
apiPath: '/api/v1/health/dialysis',
|
||
columns: ['患者', '状态'],
|
||
firstRowTexts: ['患者1'],
|
||
totalItems: 6,
|
||
hasCreateButton: true,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockDialysis as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 编写 OfflineEventList 测试**
|
||
|
||
```typescript
|
||
// apps/web/src/pages/health/OfflineEventList.test.tsx
|
||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||
import OfflineEventList from './OfflineEventList';
|
||
|
||
const mockEvents = Array.from({ length: 4 }, (_, i) => ({
|
||
id: `event-${i + 1}`,
|
||
title: `线下活动${i + 1}`,
|
||
event_date: '2026-05-20',
|
||
location: '社区活动中心',
|
||
status: 'upcoming',
|
||
max_participants: 50,
|
||
created_at: '2026-04-01T10:00:00Z',
|
||
updated_at: '2026-04-01T10:00:00Z',
|
||
version: 1,
|
||
}));
|
||
|
||
createListPageTests({
|
||
Component: OfflineEventList,
|
||
apiPath: '/api/v1/health/offline-events',
|
||
columns: ['活动名称', '日期', '状态'],
|
||
firstRowTexts: ['线下活动1'],
|
||
totalItems: 4,
|
||
hasCreateButton: true,
|
||
hasSearch: true,
|
||
hasPagination: false,
|
||
mockItems: mockEvents as Record<string, unknown>[],
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试**
|
||
|
||
Run: `cd apps/web && npx vitest run src/pages/health/DialysisManageList.test.tsx src/pages/health/OfflineEventList.test.tsx`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/web/src/pages/health/DialysisManageList.test.tsx apps/web/src/pages/health/OfflineEventList.test.tsx
|
||
git commit -m "test(web): DialysisManageList + OfflineEventList 页面测试"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: 全量验证 + 覆盖率报告
|
||
|
||
- [ ] **Step 1: 运行全部前端测试**
|
||
|
||
Run: `cd apps/web && npx vitest run --reporter=verbose`
|
||
Expected: 全部通过
|
||
|
||
- [ ] **Step 2: 生成覆盖率报告**
|
||
|
||
Run: `cd apps/web && npx vitest run --coverage`
|
||
Expected: 页面测试文件从 1 个增加到 10+ 个,覆盖率有所提升
|
||
|
||
- [ ] **Step 3: 最终提交(如有遗漏修复)**
|
||
|
||
```bash
|
||
git add -u
|
||
git commit -m "test(web): 第一批页面测试完成 — 10 个列表页 + 工厂 + 基础设施"
|
||
```
|