test(web): 核心健康管理页面测试 — 12 个页面 51 个测试用例
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

新增测试覆盖:
- PatientDetail: 5 测试(渲染/标签页/数据展示)
- AlertDashboard: 5 测试(渲染/统计卡片/告警列表)
- AlertRuleList: 5 测试(渲染/规则表格/创建按钮)
- DeviceManage: 5 测试(渲染/设备列表/筛选)
- AiAnalysisList: 6 测试(渲染/分析记录/分页)
- AiUsageDashboard: 4 测试(渲染/统计/类型分布)
- ArticleManageList: 5 测试(渲染/文章表格/分类筛选)
- PointsProductList: 5 测试(渲染/商品表格/上下架)
- PointsRuleList: 4 测试(渲染/规则表格)
- PointsOrderList: 5 测试(渲染/订单表格/状态筛选)
- StatisticsDashboard: 2 测试(渲染/权限守卫)
- DoctorSchedule: 3 测试(渲染/排班日历/科室筛选)

测试基础设施:
- 8 个新 fixture 工厂(device/analysis/points/article/alert/schedule)
- 10 组新 MSW handlers
- 5 个新权限码(devices/dashboard/oauth/ai.usage)

前端测试:527/530 通过(3 个预存失败未受影响)
This commit is contained in:
iven
2026-05-04 18:02:55 +08:00
parent f54fb336dc
commit c35ea83799
16 changed files with 1063 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createAnalysisFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import AiAnalysisList from './AiAnalysisList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock suggestion API (used by sub-component)
vi.mock('../../api/ai/suggestions', () => ({
suggestionApi: {
list: vi.fn().mockResolvedValue({ data: [], total: 0 }),
approve: vi.fn(),
},
}));
const mockAnalyses = createFixtureList(createAnalysisFixture, 6, [
{ id: 'analysis-1', analysis_type: 'lab_report_interpretation', status: 'completed', patient_name: '张三' },
{ id: 'analysis-2', analysis_type: 'health_trend_analysis', status: 'streaming', patient_name: '李四' },
]);
createListPageTests({
Component: AiAnalysisList,
apiPath: '/api/v1/ai/analysis/history',
columns: ['分析类型', '患者', '模型', '状态'],
firstRowTexts: ['化验单解读'],
totalItems: 6,
hasCreateButton: false,
hasSearch: true,
hasPagination: true,
mockItems: mockAnalyses as Record<string, unknown>[],
});
describe('AiAnalysisList extra tests', () => {
it('renders page header with title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AiAnalysisList />);
await waitFor(() => {
expect(screen.getByText('AI 分析历史')).toBeInTheDocument();
});
});
it('shows analysis type filter dropdown', async () => {
renderWithProviders(<AiAnalysisList />);
await waitFor(() => {
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
import AiUsageDashboard from './AiUsageDashboard';
describe('AiUsageDashboard', () => {
it('renders page title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('AI 用量统计')).toBeInTheDocument();
});
});
it('shows statistics cards', async () => {
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('总分析次数')).toBeInTheDocument();
});
expect(screen.getByText('分析类型数')).toBeInTheDocument();
expect(screen.getByText('本月分析')).toBeInTheDocument();
});
it('shows analysis type distribution section', async () => {
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('分析类型分布')).toBeInTheDocument();
});
});
it('renders type distribution labels', async () => {
renderWithProviders(<AiUsageDashboard />);
await waitFor(() => {
expect(screen.getByText('化验单解读')).toBeInTheDocument();
});
expect(screen.getByText('趋势分析')).toBeInTheDocument();
expect(screen.getByText('体检方案')).toBeInTheDocument();
expect(screen.getByText('报告摘要')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
// Mock SSE hook
vi.mock('../../hooks/useAlertSSE', () => ({
useAlertSSE: () => ({ connected: false, connectionState: 'disconnected', recentAlerts: [], reconnect: vi.fn() }),
}));
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
import AlertDashboard from './AlertDashboard';
describe('AlertDashboard', () => {
it('renders the dashboard title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('告警仪表盘')).toBeInTheDocument();
});
});
it('shows statistics cards', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('待处理')).toBeInTheDocument();
});
expect(screen.getByText('已确认')).toBeInTheDocument();
expect(screen.getByText('危急值')).toBeInTheDocument();
});
it('shows alert list card', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('告警列表')).toBeInTheDocument();
});
});
it('shows SSE connection status indicator', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('连接断开')).toBeInTheDocument();
});
});
it('shows status filter dropdown', async () => {
renderWithProviders(<AlertDashboard />);
await waitFor(() => {
expect(screen.getByText('告警仪表盘')).toBeInTheDocument();
});
// The status filter Select should be present
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createAlertRuleFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import AlertRuleList from './AlertRuleList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockRules = createFixtureList(createAlertRuleFixture, 5, [
{ id: 'rule-1', name: '血压偏高告警' },
{ id: 'rule-2', name: '心率异常告警' },
]);
createListPageTests({
Component: AlertRuleList,
apiPath: '/api/v1/health/alert-rules',
columns: ['规则名称', '指标类型', '条件类型', '严重程度'],
firstRowTexts: ['血压偏高告警'],
totalItems: 5,
hasCreateButton: true,
createButtonText: '新建规则',
hasSearch: false,
hasPagination: false,
mockItems: mockRules as Record<string, unknown>[],
});
describe('AlertRuleList extra tests', () => {
it('renders severity tags in the table', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<AlertRuleList />);
await waitFor(() => {
const tags = document.querySelectorAll('.ant-tag');
expect(tags.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createArticleFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import ArticleManageList from './ArticleManageList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockArticles = createFixtureList(createArticleFixture, 10, [
{ id: 'article-1', title: '健康饮食指南', status: 'published', category_name: '营养健康' },
{ id: 'article-2', title: '运动与健康', status: 'draft', category_name: '运动健身' },
]);
createListPageTests({
Component: ArticleManageList,
apiPath: '/api/v1/health/articles',
columns: ['标题', '分类', '状态'],
firstRowTexts: ['健康饮食指南'],
totalItems: 10,
hasCreateButton: true,
createButtonText: '新建文章',
hasSearch: true,
hasPagination: true,
mockItems: mockArticles as Record<string, unknown>[],
});
describe('ArticleManageList extra tests', () => {
it('renders status tab options after data loads', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<ArticleManageList />);
// Wait for the table to render (data loaded)
await waitFor(() => {
const table = document.querySelector('.ant-table');
expect(table).toBeInTheDocument();
});
// Check tab items exist — Ant Design Tabs render tab labels
const tabItems = document.querySelectorAll('[role="tab"]');
const tabTexts = Array.from(tabItems).map((t) => t.textContent?.trim());
// At least some tab text should be present
expect(tabTexts.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createDeviceFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import DeviceManage from './DeviceManage';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockDevices = createFixtureList(createDeviceFixture, 10, [
{ id: 'device-1', device_model: 'BP Monitor Pro', device_type: 'blood_pressure', status: 'online' },
{ id: 'device-2', device_model: 'GlucoSense Lite', device_type: 'blood_glucose', status: 'offline' },
]);
createListPageTests({
Component: DeviceManage,
apiPath: '/api/v1/health/devices',
columns: ['设备 ID', '设备型号', '设备类型', '状态'],
firstRowTexts: ['BP Monitor Pro'],
totalItems: 10,
hasCreateButton: false,
hasSearch: true,
hasPagination: true,
mockItems: mockDevices as Record<string, unknown>[],
});
describe('DeviceManage extra tests', () => {
it('shows filter controls for device type and status', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<DeviceManage />);
await waitFor(() => {
expect(screen.getByText('设备管理')).toBeInTheDocument();
});
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock CalendarView since it uses complex dayjs/calendar logic
vi.mock('./components/CalendarView', () => ({
CalendarView: () => <div data-testid="calendar-view"></div>,
}));
import DoctorSchedule from './DoctorSchedule';
describe('DoctorSchedule', () => {
it('renders the schedule page with doctor select', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<DoctorSchedule />);
await waitFor(() => {
expect(screen.getByText('选择医护:')).toBeInTheDocument();
});
});
it('shows empty state when no doctor selected', async () => {
renderWithProviders(<DoctorSchedule />);
await waitFor(() => {
expect(screen.getByText('请先选择医护以查看排班')).toBeInTheDocument();
});
});
it('has a card container', async () => {
renderWithProviders(<DoctorSchedule />);
await waitFor(() => {
const card = document.querySelector('.ant-card');
expect(card).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/mocks/server';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import PatientDetail from './PatientDetail';
// Mock useParams to return patient ID
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useParams: () => ({ id: 'patient-1' }),
};
});
// Mock SSE hook
vi.mock('../../hooks/useAlertSSE', () => ({
useAlertSSE: () => ({ connected: false, connectionState: 'disconnected', recentAlerts: [], reconnect: vi.fn() }),
}));
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock AI SSE analysis
vi.mock('../../api/ai/analysisSse', () => ({
startAnalysis: vi.fn(),
}));
const mockPatient = {
id: 'patient-1',
name: '张三',
gender: 'male',
birth_date: '1990-01-15',
blood_type: 'A',
id_number: '110101199001150001',
status: 'active',
verification_status: 'verified',
source: 'manual',
allergy_history: '青霉素过敏',
medical_history_summary: '高血压',
emergency_contact_name: '李四',
emergency_contact_phone: '13800000001',
notes: '定期复查',
created_at: '2026-04-01T10:00:00Z',
updated_at: '2026-04-01T10:00:00Z',
version: 1,
};
function setupPatientDetail(patientId = 'patient-1') {
server.use(
http.get(`/api/v1/health/patients/${patientId}`, async () => {
return HttpResponse.json({ success: true, data: mockPatient });
}),
);
}
describe('PatientDetail', () => {
it('renders patient detail with basic info', async () => {
vi.setConfig({ testTimeout: 15000 });
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('返回列表')).toBeInTheDocument();
});
// 张三 appears multiple times (header + description)
const nameElements = screen.getAllByText('张三');
expect(nameElements.length).toBeGreaterThanOrEqual(1);
});
it('displays patient description fields', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('青霉素过敏')).toBeInTheDocument();
});
expect(screen.getByText('高血压')).toBeInTheDocument();
expect(screen.getByText('李四')).toBeInTheDocument();
expect(screen.getByText('13800000001')).toBeInTheDocument();
});
it('shows quick navigation links', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('预约记录')).toBeInTheDocument();
});
expect(screen.getByText('咨询记录')).toBeInTheDocument();
expect(screen.getByText('透析记录')).toBeInTheDocument();
expect(screen.getByText('随访任务')).toBeInTheDocument();
});
it('shows edit button for authorized users', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('编辑信息')).toBeInTheDocument();
});
});
it('shows tab labels', async () => {
setupPatientDetail();
renderWithProviders(<PatientDetail />);
await waitFor(() => {
expect(screen.getByText('基本信息')).toBeInTheDocument();
});
expect(screen.getByText('家属管理')).toBeInTheDocument();
expect(screen.getByText('健康数据')).toBeInTheDocument();
expect(screen.getByText('随访记录')).toBeInTheDocument();
expect(screen.getByText('积分账户')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createPointsOrderFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import PointsOrderList from './PointsOrderList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock Zustand store
vi.mock('../../stores/health', () => ({
useHealthStore: () => ({
batchResolvePatientNames: vi.fn(),
getPatientName: (id: string) => `患者-${id}`,
}),
}));
const mockOrders = createFixtureList(createPointsOrderFixture, 8, [
{ id: 'order-00000001-abcd', product_name: '体检套餐兑换券', status: 'pending', points_cost: 100 },
{ id: 'order-00000002-abcd', product_name: '健康手环', status: 'verified', points_cost: 200 },
]);
createListPageTests({
Component: PointsOrderList,
apiPath: '/api/v1/health/points/orders',
columns: ['订单号', '商品', '积分', '状态'],
firstRowTexts: ['体检套餐兑换券'],
totalItems: 8,
hasCreateButton: true,
createButtonText: '核销订单',
hasSearch: true,
hasPagination: true,
mockItems: mockOrders as Record<string, unknown>[],
});
describe('PointsOrderList extra tests', () => {
it('renders the page title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<PointsOrderList />);
await waitFor(() => {
expect(screen.getByText('积分订单')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { createListPageTests } from '../../test/factories/listPageTests';
import { createFixtureList, createPointsProductFixture } from '../../test/fixtures';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
import PointsProductList from './PointsProductList';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
const mockProducts = createFixtureList(createPointsProductFixture, 5, [
{ id: 'product-1', name: '体检套餐兑换券', product_type: 'service', points_cost: 100 },
{ id: 'product-2', name: '健康手环', product_type: 'physical', points_cost: 200 },
]);
createListPageTests({
Component: PointsProductList,
apiPath: '/api/v1/health/points/products',
columns: ['商品名称', '类型', '积分', '库存', '状态'],
firstRowTexts: ['体检套餐兑换券'],
totalItems: 5,
hasCreateButton: true,
createButtonText: '新建商品',
hasSearch: true,
hasPagination: true,
mockItems: mockProducts as Record<string, unknown>[],
});
describe('PointsProductList extra tests', () => {
it('renders the page title', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<PointsProductList />);
await waitFor(() => {
expect(screen.getByText('积分商品')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
import PointsRuleList from './PointsRuleList';
describe('PointsRuleList', () => {
it('renders page title and table', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
expect(screen.getByText('积分规则')).toBeInTheDocument();
});
});
it('shows create button', async () => {
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /新建规则/ })).toBeInTheDocument();
});
});
it('renders table with column headers', async () => {
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
const table = document.querySelector('.ant-table');
expect(table).toBeInTheDocument();
});
const headers = document.querySelectorAll('th');
const headerTexts = Array.from(headers).map((h) => h.textContent?.trim());
expect(headerTexts.some((t) => t?.includes('规则名称'))).toBe(true);
expect(headerTexts.some((t) => t?.includes('事件类型'))).toBe(true);
expect(headerTexts.some((t) => t?.includes('积分值'))).toBe(true);
});
it('shows filter controls', async () => {
renderWithProviders(<PointsRuleList />);
await waitFor(() => {
const selects = document.querySelectorAll('.ant-select');
expect(selects.length).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/utils/renderWithProviders';
// Mock useThemeMode
vi.mock('../../hooks/useThemeMode', () => ({
useThemeMode: () => false,
}));
// Mock the sub-dashboard components to simplify testing
vi.mock('./StatisticsDashboard/DoctorDashboard', () => ({
DoctorDashboard: () => <div data-testid="doctor-dashboard"></div>,
}));
vi.mock('./StatisticsDashboard/NurseDashboard', () => ({
NurseDashboard: () => <div data-testid="nurse-dashboard"></div>,
}));
vi.mock('./StatisticsDashboard/AdminDashboard', () => ({
AdminDashboard: () => <div data-testid="admin-dashboard"></div>,
}));
vi.mock('./StatisticsDashboard/OperatorDashboard', () => ({
OperatorDashboard: () => <div data-testid="operator-dashboard"></div>,
}));
import StatisticsDashboard from './StatisticsDashboard';
describe('StatisticsDashboard', () => {
it('renders a dashboard component based on role', async () => {
vi.setConfig({ testTimeout: 15000 });
renderWithProviders(<StatisticsDashboard />);
await waitFor(() => {
// Default role is 'admin' when no user roles are set
const adminDashboard = screen.queryByTestId('admin-dashboard');
const doctorDashboard = screen.queryByTestId('doctor-dashboard');
// One of these should render (depends on default role)
expect(adminDashboard || doctorDashboard).toBeTruthy();
});
});
it('does not render 403 when user has permission', async () => {
renderWithProviders(<StatisticsDashboard />);
await waitFor(() => {
expect(screen.queryByText('权限不足')).not.toBeInTheDocument();
});
});
});