Compare commits
7 Commits
8f7f75ac25
...
f4b5d55f24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4b5d55f24 | ||
|
|
6709df62ed | ||
|
|
c0e0e2a6c3 | ||
|
|
37cdeebb95 | ||
|
|
c93ae0bc66 | ||
|
|
0e789b530a | ||
|
|
120df86e58 |
20
apps/web/src/pages/health/AlertList.test.tsx
Normal file
20
apps/web/src/pages/health/AlertList.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||||
import { createFixtureList, createAlertFixture } from '../../test/fixtures';
|
||||
import AlertList from './AlertList';
|
||||
|
||||
const mockAlerts = createFixtureList(createAlertFixture, 12, [
|
||||
{ id: 'alert-1', severity: 'high', status: 'active', message: '血压异常偏高' },
|
||||
{ id: 'alert-2', severity: 'medium', status: 'active', message: '心率偏高' },
|
||||
]);
|
||||
|
||||
createListPageTests({
|
||||
Component: AlertList,
|
||||
apiPath: '/api/v1/health/alerts',
|
||||
columns: ['严重程度', '状态'],
|
||||
firstRowTexts: ['血压异常偏高'],
|
||||
totalItems: 12,
|
||||
hasCreateButton: false,
|
||||
hasSearch: true,
|
||||
hasPagination: false,
|
||||
mockItems: mockAlerts as Record<string, unknown>[],
|
||||
});
|
||||
21
apps/web/src/pages/health/AppointmentList.test.tsx
Normal file
21
apps/web/src/pages/health/AppointmentList.test.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||||
import { createFixtureList, createAppointmentFixture } from '../../test/fixtures';
|
||||
import AppointmentList from './AppointmentList';
|
||||
|
||||
const mockAppointments = createFixtureList(createAppointmentFixture, 15, [
|
||||
{ id: 'appt-1', patient_name: '张三', doctor_name: '李医生', status: 'pending' },
|
||||
{ id: 'appt-2', patient_name: '王五', doctor_name: '赵医生', status: 'confirmed' },
|
||||
]);
|
||||
|
||||
createListPageTests({
|
||||
Component: AppointmentList,
|
||||
apiPath: '/api/v1/health/appointments',
|
||||
columns: ['患者', '医护', '预约日期', '状态'],
|
||||
firstRowTexts: ['张三'],
|
||||
totalItems: 15,
|
||||
hasCreateButton: true,
|
||||
createButtonText: '新建预约',
|
||||
hasSearch: true,
|
||||
hasPagination: false,
|
||||
mockItems: mockAppointments as Record<string, unknown>[],
|
||||
});
|
||||
28
apps/web/src/pages/health/ConsultationList.test.tsx
Normal file
28
apps/web/src/pages/health/ConsultationList.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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/consultation-sessions',
|
||||
columns: ['患者', '医护', '状态'],
|
||||
firstRowTexts: ['患者1'],
|
||||
totalItems: 8,
|
||||
hasCreateButton: true,
|
||||
createButtonText: '新建会话',
|
||||
hasSearch: true,
|
||||
hasPagination: false,
|
||||
mockItems: mockConsultations as Record<string, unknown>[],
|
||||
});
|
||||
30
apps/web/src/pages/health/DialysisManageList.test.tsx
Normal file
30
apps/web/src/pages/health/DialysisManageList.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||||
import DialysisManageList from './DialysisManageList';
|
||||
|
||||
// DialysisManageList 需要先选择患者才会加载表格数据
|
||||
// 使用 route 带 patient_id 参数模拟已有患者选择的场景
|
||||
const mockDialysis = Array.from({ length: 6 }, (_, i) => ({
|
||||
id: `dialysis-${i + 1}`,
|
||||
patient_id: `patient-1`,
|
||||
patient_name: `患者1`,
|
||||
dialysis_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-records',
|
||||
columns: ['透析日期', '状态'],
|
||||
firstRowTexts: ['患者1'],
|
||||
totalItems: 6,
|
||||
hasCreateButton: true,
|
||||
createButtonText: '添加记录',
|
||||
hasSearch: true,
|
||||
hasPagination: false,
|
||||
route: '/?patient_id=patient-1',
|
||||
mockItems: mockDialysis as Record<string, unknown>[],
|
||||
});
|
||||
18
apps/web/src/pages/health/DoctorList.test.tsx
Normal file
18
apps/web/src/pages/health/DoctorList.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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>[],
|
||||
});
|
||||
26
apps/web/src/pages/health/FollowUpRecordList.test.tsx
Normal file
26
apps/web/src/pages/health/FollowUpRecordList.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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: ['record-1'],
|
||||
totalItems: 10,
|
||||
hasCreateButton: false,
|
||||
hasSearch: true,
|
||||
hasPagination: false,
|
||||
mockItems: mockRecords as Record<string, unknown>[],
|
||||
});
|
||||
27
apps/web/src/pages/health/FollowUpTaskList.test.tsx
Normal file
27
apps/web/src/pages/health/FollowUpTaskList.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createListPageTests } from '../../test/factories/listPageTests';
|
||||
import FollowUpTaskList from './FollowUpTaskList';
|
||||
|
||||
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>[],
|
||||
});
|
||||
29
apps/web/src/pages/health/FollowUpTemplateList.test.tsx
Normal file
29
apps/web/src/pages/health/FollowUpTemplateList.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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',
|
||||
follow_up_type: 'phone',
|
||||
field_count: 3,
|
||||
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,
|
||||
createButtonText: '新建模板',
|
||||
hasSearch: false,
|
||||
hasPagination: false,
|
||||
mockItems: mockTemplates as Record<string, unknown>[],
|
||||
});
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
type FollowUpTemplateListItem,
|
||||
type FollowUpTemplate,
|
||||
type TemplateFieldReq,
|
||||
} from '../../../api/health/followUpTemplates';
|
||||
import { AuthButton } from '../../../components/AuthButton';
|
||||
} from '../../api/health/followUpTemplates';
|
||||
import { AuthButton } from '../../components/AuthButton';
|
||||
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
phone: '电话', outpatient: '门诊', home_visit: '家访',
|
||||
|
||||
29
apps/web/src/pages/health/OfflineEventList.test.tsx
Normal file
29
apps/web/src/pages/health/OfflineEventList.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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,
|
||||
points_reward: 100,
|
||||
participant_count: i * 10,
|
||||
created_at: '2026-04-01T10:00:00Z',
|
||||
updated_at: '2026-04-01T10:00:00Z',
|
||||
version: 1,
|
||||
}));
|
||||
|
||||
createListPageTests({
|
||||
Component: OfflineEventList,
|
||||
apiPath: '/api/v1/health/admin/offline-events',
|
||||
columns: ['活动名称', '活动日期', '状态'],
|
||||
firstRowTexts: ['线下活动1'],
|
||||
totalItems: 4,
|
||||
hasCreateButton: true,
|
||||
createButtonText: '新建活动',
|
||||
hasSearch: true,
|
||||
hasPagination: false,
|
||||
mockItems: mockEvents as Record<string, unknown>[],
|
||||
});
|
||||
23
apps/web/src/pages/health/PatientList.test.tsx
Normal file
23
apps/web/src/pages/health/PatientList.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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, [
|
||||
{ id: 'patient-1', name: '张三', gender: 'male' },
|
||||
{ id: 'patient-2', 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>[],
|
||||
});
|
||||
8
apps/web/src/test/factories/listPageTests.test.tsx
Normal file
8
apps/web/src/test/factories/listPageTests.test.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
129
apps/web/src/test/factories/listPageTests.tsx
Normal file
129
apps/web/src/test/factories/listPageTests.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
import type { ComponentType } from 'react';
|
||||
import { renderWithProviders } from '../utils/renderWithProviders';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function createListPageTests(config: ListPageTestConfig) {
|
||||
const {
|
||||
Component,
|
||||
apiPath,
|
||||
columns,
|
||||
totalItems,
|
||||
hasCreateButton = true,
|
||||
createButtonText = '新建',
|
||||
hasSearch = true,
|
||||
hasPagination = true,
|
||||
route = '/',
|
||||
extraTests,
|
||||
} = config;
|
||||
|
||||
describe(`${Component.displayName || Component.name || 'ListPage'}`, () => {
|
||||
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 () => {
|
||||
vi.setConfig({ testTimeout: 15000 });
|
||||
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();
|
||||
});
|
||||
const inputs = document.querySelectorAll('input, .ant-select, .ant-picker');
|
||||
expect(inputs.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
if (extraTests) {
|
||||
extraTests();
|
||||
}
|
||||
});
|
||||
}
|
||||
97
apps/web/src/test/fixtures/healthFixtures.ts
vendored
Normal file
97
apps/web/src/test/fixtures/healthFixtures.ts
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
// --- 患者 ---
|
||||
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) => {
|
||||
const autoId = { id: `${i + 1}` };
|
||||
return factory({ ...autoId, ...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),
|
||||
};
|
||||
}
|
||||
1
apps/web/src/test/fixtures/index.ts
vendored
Normal file
1
apps/web/src/test/fixtures/index.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from './healthFixtures';
|
||||
@@ -1,4 +1,10 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import {
|
||||
patientHandlers,
|
||||
alertHandlers,
|
||||
appointmentHandlers,
|
||||
doctorHandlers,
|
||||
} from './healthHandlers';
|
||||
|
||||
const TOKEN_EXPIRES = 3600;
|
||||
|
||||
@@ -27,4 +33,10 @@ export const authHandlers = [
|
||||
),
|
||||
];
|
||||
|
||||
export const handlers = [...authHandlers];
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...patientHandlers,
|
||||
...alertHandlers,
|
||||
...appointmentHandlers,
|
||||
...doctorHandlers,
|
||||
];
|
||||
|
||||
128
apps/web/src/test/mocks/healthHandlers.ts
Normal file
128
apps/web/src/test/mocks/healthHandlers.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
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: '' } });
|
||||
}),
|
||||
];
|
||||
|
||||
// --- 告警列表 ---
|
||||
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);
|
||||
}),
|
||||
];
|
||||
@@ -1,7 +1,36 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { beforeAll, afterEach, afterAll } from 'vitest';
|
||||
import { beforeAll, afterEach, afterAll, vi } from 'vitest';
|
||||
import { server } from './mocks/server';
|
||||
|
||||
// Ant Design 依赖 window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// ResizeObserver mock(Ant Design Table 依赖)
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as any;
|
||||
|
||||
// Ant Design Modal/Drawer 依赖 getComputedStyle
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
|
||||
const style = originalGetComputedStyle(elt, pseudoElt);
|
||||
return style;
|
||||
};
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
26
apps/web/src/test/utils/renderWithProviders.test.tsx
Normal file
26
apps/web/src/test/utils/renderWithProviders.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
88
apps/web/src/test/utils/renderWithProviders.tsx
Normal file
88
apps/web/src/test/utils/renderWithProviders.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
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';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { clearApiCache } from '../../api/client';
|
||||
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
route?: string;
|
||||
/** 初始化 auth store 状态(默认已认证 + 全权限) */
|
||||
authState?: Partial<{
|
||||
user: { id: string; username: string; tenant_id: string };
|
||||
isAuthenticated: boolean;
|
||||
permissions: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const ALL_HEALTH_PERMISSIONS = [
|
||||
'health.patient.manage',
|
||||
'health.patient.list',
|
||||
'health.doctor.manage',
|
||||
'health.doctor.list',
|
||||
'health.appointment.manage',
|
||||
'health.appointment.list',
|
||||
'health.alerts.manage',
|
||||
'health.alerts.list',
|
||||
'health.follow-up.manage',
|
||||
'health.follow-up.list',
|
||||
'health.follow-up-templates.manage',
|
||||
'health.follow-up-templates.list',
|
||||
'health.consultation.manage',
|
||||
'health.consultation.list',
|
||||
'health.dialysis.manage',
|
||||
'health.dialysis.list',
|
||||
'health.articles.manage',
|
||||
'health.articles.list',
|
||||
'health.articles.review',
|
||||
'health.points.manage',
|
||||
'health.points.list',
|
||||
'health.health-data.manage',
|
||||
'health.health-data.list',
|
||||
'ai.analysis.manage',
|
||||
'ai.analysis.list',
|
||||
'ai.prompt.manage',
|
||||
'ai.prompt.list',
|
||||
];
|
||||
|
||||
const DEFAULT_AUTH = {
|
||||
user: { id: 'test-user-id', username: 'admin', tenant_id: 'test-tenant-id' },
|
||||
isAuthenticated: true,
|
||||
permissions: ALL_HEALTH_PERMISSIONS,
|
||||
};
|
||||
|
||||
// 简单 JWT payload — 不会过期
|
||||
const MOCK_TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQiLCJleHAiOjk5OTk5OTk5OTl9.mock';
|
||||
|
||||
export function renderWithProviders(ui: ReactElement, options: CustomRenderOptions = {}) {
|
||||
const { route = '/', authState, ...renderOptions } = options;
|
||||
|
||||
// 初始化 localStorage token(API client 依赖)
|
||||
localStorage.setItem('access_token', MOCK_TOKEN);
|
||||
localStorage.setItem('refresh_token', MOCK_TOKEN);
|
||||
|
||||
// 初始化 Zustand auth store
|
||||
const auth = { ...DEFAULT_AUTH, ...authState };
|
||||
useAuthStore.setState({
|
||||
user: auth.user ?? null,
|
||||
isAuthenticated: auth.isAuthenticated ?? true,
|
||||
permissions: auth.permissions ?? ['*'],
|
||||
loading: false,
|
||||
} as any);
|
||||
|
||||
// 清除 API 缓存
|
||||
clearApiCache();
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<ConfigProvider locale={zhCN}>{children}</ConfigProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...renderOptions });
|
||||
}
|
||||
|
||||
export * from '@testing-library/react';
|
||||
Reference in New Issue
Block a user