feat(frontend): Phase 1 UX foundation — react-router v6 + responsive AppShell
Atomic foundation for the consensus-approved UX modernization (makes the platform URL-addressable, refresh-safe, and mobile-usable for hospital demos). - Migrate hand-rolled useState page switching → react-router v6 (routes.tsx, thin App.tsx auth gate, NavLink SideNav, lazy+Suspense per route) - Add responsive AppShell: persistent rail (lg:) ⇄ off-canvas drawer + hamburger (<lg); kills hardcoded ml-[200px]; usable at 375px - Add ui primitive kit (Skeleton/LoadingState/EmptyState/Card/Panel/Segmented) - Sweep all raw "加载中..." text loaders → skeleton primitives (G4) - Centralize test ids (utils/testids.ts); rewrite e2e for URL nav (17/17 pass: deep-link, refresh-preserves-page, back, 375px drawer/no-scroll) - Harden DemographicAnalysis against API shape mismatch (defensive normalize) - gitignore playwright-report/ and test-results/ Gates: tsc --noEmit 0 · pnpm build ok · e2e 17/17 · grep 加载中 zero outside ui/ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,242 +1,291 @@
|
||||
/**
|
||||
* US-007 + US-008: E2E user flow and UI state tests.
|
||||
* Simulates real user workflows through the CBPOA system.
|
||||
* Phase-1 acceptance tests: URL-based navigation, responsive layout, and core user flows.
|
||||
* Rewrites the previous click-nav suite for react-router v6 URL navigation.
|
||||
*
|
||||
* Auth strategy: seed localStorage['cbpoa_token'] via addInitScript (App.tsx gates on
|
||||
* token presence only; no server validation). Backend is mocked via page.route so the
|
||||
* suite runs hermetically without a live :8000 backend.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { TESTIDS } from '../src/utils/testids';
|
||||
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('认证流程 (Authentication Flow)', () => {
|
||||
test('显示登录页面', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(1000);
|
||||
// Should see login form or app (if cached token)
|
||||
const isLogin = await page.locator('input').count();
|
||||
const isApp = await page.locator('nav').count();
|
||||
expect(isLogin > 0 || isApp > 0).toBeTruthy();
|
||||
/** Seed auth token and mock all /api/** calls before each page load. */
|
||||
async function seedAuthAndMockApi(page: Page) {
|
||||
// Prevent login gate from appearing.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('cbpoa_token', 'e2e-test-token');
|
||||
});
|
||||
|
||||
test('登录表单可交互', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(1000);
|
||||
// Mock backend responses so the suite is hermetic — no live :8000 required.
|
||||
// Each response must match the TypeScript interface shape; returning {} causes
|
||||
// pages to throw when accessing expected array properties.
|
||||
await page.route('/api/**', (route) => {
|
||||
const url = route.request().url();
|
||||
|
||||
const inputs = page.locator('input');
|
||||
const count = await inputs.count();
|
||||
|
||||
if (count >= 2) {
|
||||
// Login page is shown
|
||||
await inputs.first().fill('admin');
|
||||
await inputs.nth(1).fill('admin123');
|
||||
|
||||
const loginBtn = page.locator('button[type="submit"], button:has-text("登录"), button:has-text("Login")');
|
||||
const btnCount = await loginBtn.count();
|
||||
if (btnCount > 0) {
|
||||
await loginBtn.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
if (url.includes('/alerts')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ alerts: [], total: 0 }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If no inputs, user is already logged in (token in localStorage)
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('监测面板 (Monitoring Dashboard)', () => {
|
||||
test('面板加载并显示统计卡片', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should show monitoring page by default
|
||||
const statCards = page.locator('[class*="stat"], [class*="card"], [class*="Stat"]');
|
||||
const cardsCount = await statCards.count();
|
||||
|
||||
// Should see some content
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toBeTruthy();
|
||||
});
|
||||
|
||||
test('时间线控件可交互', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Look for timeline controls
|
||||
const playButton = page.locator('button:has-text("播放"), button[title*="play" i], button[class*="play" i]');
|
||||
const prevButton = page.locator('button:has-text("前一天"), button[title*="prev" i]');
|
||||
const nextButton = page.locator('button:has-text("后一天"), button[title*="next" i]');
|
||||
|
||||
if (await playButton.count() > 0) {
|
||||
await playButton.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
if (url.includes('/history/aggregated')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
test('疾病筛选器可用', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const selects = page.locator('select, [role="combobox"], [class*="select" i], [class*="filter" i]');
|
||||
const count = await selects.count();
|
||||
expect(count >= 0).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('预警面板 (Alerts Dashboard)', () => {
|
||||
test('导航到预警面板', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Navigate to alerts - click sidebar link
|
||||
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), button:has-text("告警"), span:has-text("预警"), span:has-text("告警")');
|
||||
if (await alertsLink.count() > 0) {
|
||||
await alertsLink.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
if (url.includes('/grids')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
test('预警列表加载', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const alertsLink = page.locator('a[href*="alert" i], button:has-text("预警"), span:has-text("预警")');
|
||||
if (await alertsLink.count() > 0) {
|
||||
await alertsLink.first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toBeTruthy();
|
||||
// DemographicsResponse — used by DemographicAnalysis page.
|
||||
if (url.includes('/cases/demographics')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
age_distribution: [],
|
||||
gender_split: { male: { outpatient: 0, inpatient: 0 }, female: { outpatient: 0, inpatient: 0 } },
|
||||
age_diagnosis_matrix: [],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('趋势分析 (Trend Analysis)', () => {
|
||||
test('导航到趋势分析页面', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势"), a[href*="trend" i]');
|
||||
if (await trendLink.count() > 0) {
|
||||
await trendLink.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
// DiseaseAnalysis calls: diagnosis-distribution, seasonality, districts.
|
||||
if (url.includes('/cases/diagnosis-distribution') || url.includes('/cases/disease-seasonality')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
test('趋势图渲染', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const trendLink = page.locator('button:has-text("趋势"), span:has-text("趋势")');
|
||||
if (await trendLink.count() > 0) {
|
||||
await trendLink.first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Recharts renders SVG charts
|
||||
const svgCharts = page.locator('svg.recharts-surface');
|
||||
const chartCount = await svgCharts.count();
|
||||
expect(chartCount >= 0).toBeTruthy();
|
||||
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('区县对比 (District Comparison)', () => {
|
||||
test('导航到区县对比页面', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const districtLink = page.locator('button:has-text("区县"), button:has-text("对比"), span:has-text("区县")');
|
||||
if (await districtLink.count() > 0) {
|
||||
await districtLink.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
// Trend / time-series endpoints.
|
||||
if (url.includes('/cases/trend') || url.includes('/cases')) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [], total: 0 }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('报告中心 (Reports Center)', () => {
|
||||
test('导航到报告中心', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告"), a[href*="report" i]');
|
||||
if (await reportsLink.count() > 0) {
|
||||
await reportsLink.first().click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
});
|
||||
|
||||
test('报告列表加载', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const reportsLink = page.locator('button:has-text("报告"), span:has-text("报告")');
|
||||
if (await reportsLink.count() > 0) {
|
||||
await reportsLink.first().click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('UI 状态与错误处理 (UI States & Error Handling)', () => {
|
||||
test('页面加载显示加载指示器而非白屏', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const bodyHTML = await page.innerHTML('body');
|
||||
// Should have some content, even during loading
|
||||
expect(bodyHTML.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('侧边栏导航切换页面正常', async ({ page }) => {
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const navLinks = page.locator('nav a, nav button, [class*="side" i] a, [class*="side" i] button');
|
||||
const count = await navLinks.count();
|
||||
|
||||
if (count >= 2) {
|
||||
await navLinks.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await navLinks.nth(1).click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
});
|
||||
|
||||
test('未出现明显 console 报错', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
errors.push(err.message);
|
||||
// Default fallback — return a safe empty object.
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(3000);
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL-based navigation (react-router v6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const filtered = errors.filter(
|
||||
(e) => !e.includes('favicon') && !e.includes('404') && !e.includes('OLMap')
|
||||
test.describe('URL-based navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuthAndMockApi(page);
|
||||
});
|
||||
|
||||
test('deep-link: /analysis/disease mounts page-disease directly', async ({ page }) => {
|
||||
await page.goto('/analysis/disease');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('refresh preserves page: reload on /analysis/disease keeps URL and mounts page-disease', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/analysis/disease');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page).toHaveURL(/\/analysis\/disease/);
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('browser back: from /analysis/trend back to /monitoring restores page-monitoring', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
|
||||
await page.goto('/analysis/trend');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
|
||||
|
||||
await page.goBack();
|
||||
|
||||
await expect(page).toHaveURL(/\/monitoring/);
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('NavLink click updates URL to /alerts and mounts page-alerts', async ({ page }) => {
|
||||
// Start on /monitoring. The SideNav collapses all modules except the active one,
|
||||
// so nav-alerts (inside the "预警" module) is hidden behind a collapsed section.
|
||||
// We must expand the "预警" module first by clicking its header button.
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
|
||||
// Expand the "预警" module section so nav-alerts NavLink becomes visible.
|
||||
// Both sidebar-rail and app-drawer render a SideNav; scope to sidebar-rail to avoid
|
||||
// strict-mode ambiguity (the app-drawer's copy is also in the DOM but off-screen).
|
||||
await page
|
||||
.locator(`[data-testid="${TESTIDS.sidebarRail}"] button`)
|
||||
.filter({ hasText: '预警' })
|
||||
.click();
|
||||
await expect(
|
||||
page.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator(`[data-testid="${TESTIDS.sidebarRail}"] [data-testid="${TESTIDS.navAlerts}"]`)
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/alerts/);
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('root / redirects to /monitoring', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/monitoring/);
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('unknown path redirects to /monitoring', async ({ page }) => {
|
||||
await page.goto('/does-not-exist');
|
||||
await expect(page).toHaveURL(/\/monitoring/);
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responsive layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Responsive layout — mobile @375px', () => {
|
||||
test.use({ viewport: { width: 375, height: 812 } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuthAndMockApi(page);
|
||||
});
|
||||
|
||||
test('hamburger is visible and sidebar-rail is hidden at 375px', async ({ page }) => {
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('tapping hamburger slides app-drawer into viewport', async ({ page }) => {
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
|
||||
// Drawer should be off-screen (translate-x-full) before toggle.
|
||||
const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`);
|
||||
await expect(drawer).not.toBeInViewport();
|
||||
|
||||
await page.locator(`[data-testid="${TESTIDS.hamburger}"]`).click();
|
||||
|
||||
// After toggle, drawer slides in and becomes visible in viewport.
|
||||
await expect(drawer).toBeInViewport();
|
||||
});
|
||||
|
||||
test('no horizontal scroll on default route at 375px', async ({ page }) => {
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
|
||||
const noHorizontalScroll = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
|
||||
);
|
||||
expect(filtered).toHaveLength(0);
|
||||
expect(noHorizontalScroll).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('响应式布局 (Responsive Layout)', () => {
|
||||
test('移动端视口下不崩溃', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
test.describe('Responsive layout — desktop @1280px', () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toBeTruthy();
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuthAndMockApi(page);
|
||||
});
|
||||
|
||||
test('平板视口下正常显示', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto(BASE_URL);
|
||||
await page.waitForTimeout(2000);
|
||||
test('sidebar-rail is visible and hamburger is hidden at 1280px', async ({ page }) => {
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
|
||||
const bodyText = await page.textContent('body');
|
||||
expect(bodyText).toBeTruthy();
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible();
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core page loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Core pages load via URL nav', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedAuthAndMockApi(page);
|
||||
});
|
||||
|
||||
test('/monitoring loads page-monitoring', async ({ page }) => {
|
||||
await page.goto('/monitoring');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('/alerts loads page-alerts', async ({ page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('/analysis/trend loads page-trend', async ({ page }) => {
|
||||
await page.goto('/analysis/trend');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('/analysis/district loads page-district', async ({ page }) => {
|
||||
await page.goto('/analysis/district');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageDistrict}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('/analysis/reports loads page-reports', async ({ page }) => {
|
||||
await page.goto('/analysis/reports');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageReports}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('/analysis/demographics loads page-demographics', async ({ page }) => {
|
||||
await page.goto('/analysis/demographics');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageDemographics}"]`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('/analysis/environment loads page-environment', async ({ page }) => {
|
||||
await page.goto('/analysis/environment');
|
||||
await expect(page.locator(`[data-testid="${TESTIDS.pageEnvironment}"]`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user