/** * 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, 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'); }); // 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; } if (url.includes('/history/aggregated')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ aggregations: [], total_records: 0 }), }); return; } if (url.includes('/grids')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ type: 'FeatureCollection', features: [] }), }); return; } // 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; } // 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; } if (url.includes('/cases/districts') || url.includes('/districts')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]), }); return; } // 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; } // Default fallback — return a safe empty object. route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }), }); }); } // --------------------------------------------------------------------------- // URL-based navigation (react-router v6) // --------------------------------------------------------------------------- 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(noHorizontalScroll).toBe(true); }); }); test.describe('Responsive layout — desktop @1280px', () => { test.use({ viewport: { width: 1280, height: 800 } }); test.beforeEach(async ({ page }) => { await seedAuthAndMockApi(page); }); 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(); }); }); // --------------------------------------------------------------------------- // 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(); }); });