test(web): PatientList/AlertList/DoctorList 页面测试 — 验证工厂模式
- 添加 matchMedia + ResizeObserver mock (Ant Design 依赖) - renderWithProviders 注入 auth state + localStorage token - 修复 fixture 批量生成自动分配唯一 id - PatientList 5 测试 / AlertList 3 测试 / DoctorList 4 测试
This commit is contained in:
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>[],
|
||||||
|
});
|
||||||
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>[],
|
||||||
|
});
|
||||||
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>[],
|
||||||
|
});
|
||||||
5
apps/web/src/test/fixtures/healthFixtures.ts
vendored
5
apps/web/src/test/fixtures/healthFixtures.ts
vendored
@@ -79,7 +79,10 @@ export function createFixtureList<T>(
|
|||||||
count: number,
|
count: number,
|
||||||
overridesList: Record<string, unknown>[] = [],
|
overridesList: Record<string, unknown>[] = [],
|
||||||
): T[] {
|
): T[] {
|
||||||
return Array.from({ length: count }, (_, i) => factory(overridesList[i] || {}));
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const autoId = { id: `${i + 1}` };
|
||||||
|
return factory({ ...autoId, ...overridesList[i] });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 分页响应包装 ---
|
// --- 分页响应包装 ---
|
||||||
|
|||||||
@@ -1,7 +1,36 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { beforeAll, afterEach, afterAll } from 'vitest';
|
import { beforeAll, afterEach, afterAll, vi } from 'vitest';
|
||||||
import { server } from './mocks/server';
|
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' }));
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
|
||||||
afterEach(() => server.resetHandlers());
|
afterEach(() => server.resetHandlers());
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close());
|
||||||
|
|||||||
@@ -3,13 +3,76 @@ import { render, RenderOptions } from '@testing-library/react';
|
|||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { useAuthStore } from '../../stores/auth';
|
||||||
|
import { clearApiCache } from '../../api/client';
|
||||||
|
|
||||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||||
route?: string;
|
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 = {}) {
|
export function renderWithProviders(ui: ReactElement, options: CustomRenderOptions = {}) {
|
||||||
const { route = '/', ...renderOptions } = options;
|
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 }) {
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user