Phase 3 of the UX modernization. Frontend-only role view-presets (D2 — no backend, no JWT role claim, honestly labeled 视角 not 权限). Three conflict-free lanes. Role infrastructure (worker-session): - sessionStore with single swappable getRoleSource() seam (localStorage today, one-line swap to /api/auth/me for future RBAC); Role = official|community|doctor|admin - 视角 switcher in TopNav (replaces hardcoded "admin"); on change persists role + navigates to that perspective's default landing - roleViews.ts: ROLE_LABELS + roleDefaultPath (official→/overview?granularity=district, community→/monitoring?granularity=street, doctor→/alerts?view=cluster, admin→/monitoring) - RoleRedirect index route → current role's default; * fallback unchanged URL-driven granularity (worker-monitoring): - granularity (city|district|street) query param is the source of truth; drilldownStore DERIVES from it via a one-way effect; deep-linkable + reload-safe - reconciled the imperative desync — DistrictBreakdown no longer calls useDrilldownStore.getState(); MonitoringDashboard owns useSearchParams, writes URL - granularity-control Segmented (全市/区域/街道); district-rollup testid Role-aware alerts + privacy (worker-alerts): - doctor/cluster view: individual alert markers hard-locked off (effective flag is single source of truth; toggle not rendered) → aggregated density only; DiseaseFilter mounted; privacy invariant testable via hidden patient-point DOM mirror (count=0) - 官员: 100m grid hidden (grid-layer-wrapper unrendered); admin/community unchanged Gates: tsc 0 · vitest 75 · e2e 30/30 (incl 10 new P3 tests) · build ok Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
5.7 KiB
TypeScript
144 lines
5.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|