2026-06-09 12:54:55 +08:00
|
|
|
/**
|
2026-06-21 20:12:13 +08:00
|
|
|
* 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.
|
2026-06-09 12:54:55 +08:00
|
|
|
*/
|
2026-06-21 20:12:13 +08:00
|
|
|
import { test, expect, Page } from '@playwright/test';
|
|
|
|
|
import { TESTIDS } from '../src/utils/testids';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/** 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');
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// 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();
|
|
|
|
|
|
|
|
|
|
if (url.includes('/alerts')) {
|
|
|
|
|
route.fulfill({
|
|
|
|
|
status: 200,
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
body: JSON.stringify({ alerts: [], total: 0 }),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
if (url.includes('/history/aggregated')) {
|
|
|
|
|
route.fulfill({
|
|
|
|
|
status: 200,
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
body: JSON.stringify({ aggregations: [], total_records: 0 }),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
if (url.includes('/grids')) {
|
|
|
|
|
route.fulfill({
|
|
|
|
|
status: 200,
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
body: JSON.stringify({ type: 'FeatureCollection', features: [] }),
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
if (url.includes('/cases/districts') || url.includes('/districts')) {
|
|
|
|
|
route.fulfill({
|
|
|
|
|
status: 200,
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
body: JSON.stringify([]),
|
|
|
|
|
});
|
|
|
|
|
return;
|
2026-06-09 12:54:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// Default fallback — return a safe empty object.
|
|
|
|
|
route.fulfill({
|
|
|
|
|
status: 200,
|
|
|
|
|
contentType: 'application/json',
|
|
|
|
|
body: JSON.stringify({ data: [], items: [], total: 0 }),
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
2026-06-21 20:12:13 +08:00
|
|
|
}
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// URL-based navigation (react-router v6)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test.describe('URL-based navigation', () => {
|
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
|
|
|
await seedAuthAndMockApi(page);
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
await page.reload();
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/analysis\/disease/);
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageDisease}"]`)).toBeVisible();
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('root / redirects to /monitoring', async ({ page }) => {
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
await expect(page).toHaveURL(/\/monitoring/);
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Responsive layout
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test.describe('Responsive layout — mobile @375px', () => {
|
|
|
|
|
test.use({ viewport: { width: 375, height: 812 } });
|
|
|
|
|
|
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
|
|
|
await seedAuthAndMockApi(page);
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).toBeVisible();
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).not.toBeVisible();
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('tapping hamburger slides app-drawer into viewport', async ({ page }) => {
|
|
|
|
|
await page.goto('/monitoring');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// Drawer should be off-screen (translate-x-full) before toggle.
|
|
|
|
|
const drawer = page.locator(`[data-testid="${TESTIDS.appDrawer}"]`);
|
|
|
|
|
await expect(drawer).not.toBeInViewport();
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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(noHorizontalScroll).toBe(true);
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test.describe('Responsive layout — desktop @1280px', () => {
|
|
|
|
|
test.use({ viewport: { width: 1280, height: 800 } });
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test.beforeEach(async ({ page }) => {
|
|
|
|
|
await seedAuthAndMockApi(page);
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
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();
|
|
|
|
|
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.sidebarRail}"]`)).toBeVisible();
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.hamburger}"]`)).not.toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Core page loading
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test.describe('Core pages load via URL nav', () => {
|
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
|
|
|
await seedAuthAndMockApi(page);
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('/monitoring loads page-monitoring', async ({ page }) => {
|
|
|
|
|
await page.goto('/monitoring');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageMonitoring}"]`)).toBeVisible();
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('/alerts loads page-alerts', async ({ page }) => {
|
|
|
|
|
await page.goto('/alerts');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible();
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('/analysis/trend loads page-trend', async ({ page }) => {
|
|
|
|
|
await page.goto('/analysis/trend');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageTrend}"]`)).toBeVisible();
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('/analysis/district loads page-district', async ({ page }) => {
|
|
|
|
|
await page.goto('/analysis/district');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageDistrict}"]`)).toBeVisible();
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('/analysis/reports loads page-reports', async ({ page }) => {
|
|
|
|
|
await page.goto('/analysis/reports');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageReports}"]`)).toBeVisible();
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('/analysis/demographics loads page-demographics', async ({ page }) => {
|
|
|
|
|
await page.goto('/analysis/demographics');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageDemographics}"]`)).toBeVisible();
|
|
|
|
|
});
|
2026-06-09 12:54:55 +08:00
|
|
|
|
2026-06-21 20:12:13 +08:00
|
|
|
test('/analysis/environment loads page-environment', async ({ page }) => {
|
|
|
|
|
await page.goto('/analysis/environment');
|
|
|
|
|
await expect(page.locator(`[data-testid="${TESTIDS.pageEnvironment}"]`)).toBeVisible();
|
2026-06-09 12:54:55 +08:00
|
|
|
});
|
|
|
|
|
});
|