/** * Phase-3 acceptance tests: role-aware 预警 (alerts) view. * * Two view-preset invariants (D2 — frontend presets, NOT access control): * * 1. PRIVACY INVARIANT (doctor / ?view=cluster): the doctor sees ONLY the aggregated * density raster + the disease filter — ZERO individual patient/case point markers. * The page mirrors every individual marker it would actually render into a hidden * data-testid="patient-point" element (the live Leaflet CircleMarkers are canvas/SVG * objects with no testid and can't be counted directly). In cluster mode the page * forces showAlertMarkers=false, so that mirror set is empty → patient-point count 0. * * 2. 官员 (official) GRID-HIDE: the 100m 网格 is meaningless for leadership, so the grid * toggle wrapper (data-testid="grid-layer-wrapper") is not rendered at all. * * Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically * (no live :8000 backend). Role is seeded via localStorage['cbpoa_role']. */ import { test, expect, Page } from '@playwright/test'; import { TESTIDS } from '../src/utils/testids'; /** * Seed auth token (+ optional role) and mock all /api/** calls before page load. * Response shapes copied from user-flows.spec.ts. */ async function seedAuthAndMockApi(page: Page, role?: string) { await page.addInitScript((r) => { localStorage.setItem('cbpoa_token', 'e2e-test-token'); if (r) localStorage.setItem('cbpoa_role', r); }, role ?? ''); 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; } 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; } 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('/diagnoses') || url.includes('/diagnosis-list')) { 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; } if (url.includes('/cases/trend') || url.includes('/cases')) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], total: 0 }), }); return; } route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }), }); }); } test.describe('role-aware 预警 view (Phase 3)', () => { test.use({ viewport: { width: 1280, height: 800 } }); test('医生 /alerts?view=cluster: cluster-view mounts, disease filter present, ZERO patient points', async ({ page, }) => { await seedAuthAndMockApi(page, 'doctor'); await page.goto('/alerts?view=cluster'); // Page + the aggregated density (cluster) map both mount. await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toBeVisible(); // The disease filter is the doctor's core tool — it must be on the page. await expect(page.getByText('按病种筛选')).toBeVisible(); // PRIVACY INVARIANT: not a single individual patient/case point may be rendered. // Asserted at the data level (the mirrored DOM set), independent of Leaflet internals. await expect(page.getByTestId(TESTIDS.patientPoint)).toHaveCount(0); // The 预警标记 toggle (which would turn individual markers on) must be absent, // so there is no way for the doctor to opt out of the privacy invariant. await expect(page.getByRole('button', { name: '预警标记' })).toHaveCount(0); }); test('官员 /alerts: 100m grid hidden — grid-layer-wrapper not rendered', async ({ page }) => { await seedAuthAndMockApi(page, 'official'); await page.goto('/alerts'); await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); // The grid toggle wrapper must be entirely absent for leadership. await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(0); }); test('admin /alerts: full behavior — grid toggle present, no forced cluster view', async ({ page }) => { await seedAuthAndMockApi(page, 'admin'); await page.goto('/alerts'); await expect(page.locator(`[data-testid="${TESTIDS.pageAlerts}"]`)).toBeVisible(); // Admin keeps the grid toggle and is NOT forced into cluster view. await expect(page.getByTestId(TESTIDS.gridLayerWrapper)).toHaveCount(1); await expect(page.locator(`[data-testid="${TESTIDS.clusterView}"]`)).toHaveCount(0); }); });