Compare commits

...

7 Commits

Author SHA1 Message Date
iven
f4b5d55f24 fix(test): 增加页面测试超时至 15s — 覆盖率模式下避免 timeout
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
2026-05-03 23:21:58 +08:00
iven
6709df62ed test(web): 第一批列表页测试 — 7 个页面 + 修复导入路径
- AppointmentList / FollowUpTaskList / FollowUpRecordList / ConsultationList
- FollowUpTemplateList / DialysisManageList / OfflineEventList
- 修复 FollowUpTemplateList 导入路径 bug (../../../ → ../../)
2026-05-03 23:19:55 +08:00
iven
c0e0e2a6c3 test(web): PatientList/AlertList/DoctorList 页面测试 — 验证工厂模式
- 添加 matchMedia + ResizeObserver mock (Ant Design 依赖)
- renderWithProviders 注入 auth state + localStorage token
- 修复 fixture 批量生成自动分配唯一 id
- PatientList 5 测试 / AlertList 3 测试 / DoctorList 4 测试
2026-05-03 23:12:34 +08:00
iven
37cdeebb95 test(web): 添加 createListPageTests 工厂 — 6 类标准测试用例自动生成 2026-05-03 23:05:46 +08:00
iven
c93ae0bc66 test(web): 添加 renderWithProviders — MemoryRouter + AntD ConfigProvider 包裹器 2026-05-03 23:04:19 +08:00
iven
0e789b530a test(web): 添加测试数据工厂 — healthFixtures + 批量生成 + 分页包装 2026-05-03 23:03:04 +08:00
iven
120df86e58 test(web): 添加健康模块 msw handlers — 患者告警预约医生 4 组 mock API 2026-05-03 23:01:57 +08:00
20 changed files with 773 additions and 4 deletions

View 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>[],
});

View 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>[],
});

View 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>[],
});

View 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>[],
});

View 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>[],
});

View 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>[],
});

View 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>[],
});

View 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>[],
});

View File

@@ -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: '家访',

View 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>[],
});

View 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>[],
});

View 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');
});
});

View 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();
}
});
}

View 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
View File

@@ -0,0 +1 @@
export * from './healthFixtures';

View File

@@ -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,
];

View 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);
}),
];

View File

@@ -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 mockAnt 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());

View 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');
});
});

View 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 tokenAPI 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';