Files
CA/frontend/e2e/roles.spec.ts
Akiba So 8ad7e086bb feat: Phase 3 — 视角/perspective presets + URL-driven granularity + doctor privacy
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>
2026-06-21 20:46:38 +08:00

134 lines
4.7 KiB
TypeScript

/**
* Phase-3 acceptance tests: 视角/perspective switcher (D2 — frontend view-presets only).
*
* Roles are NOT access control: the switcher only changes the default landing page +
* granularity/filter presets. This suite verifies the switcher renders, selecting a role
* navigates to that role's default landing URL (with its query params), and the choice
* survives a reload (persisted to localStorage['cbpoa_role']).
*
* Auth + API mocking mirror e2e/user-flows.spec.ts so the suite runs hermetically.
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
/** Seed auth token and mock all /api/** calls (shapes copied from user-flows.spec.ts). */
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
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('/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('视角/perspective switcher (Phase 3)', () => {
// Use a desktop viewport so the top bar renders the switcher inline.
test.use({ viewport: { width: 1280, height: 800 } });
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('perspective-switcher is visible in the top bar', async ({ page }) => {
await page.goto('/monitoring');
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toBeVisible();
});
test('selecting 厅领导 (official) navigates to /overview?granularity=district', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('official');
await expect(page).toHaveURL(/\/overview/);
await expect(page).toHaveURL(/granularity=district/);
});
test('selecting 医生 (doctor) navigates to /alerts?view=cluster', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await expect(switcher).toBeVisible();
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
await expect(page).toHaveURL(/view=cluster/);
});
test('selected role persists across reload (localStorage cbpoa_role)', async ({ page }) => {
await page.goto('/monitoring');
const switcher = page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`);
await switcher.selectOption('doctor');
await expect(page).toHaveURL(/\/alerts/);
// localStorage should now hold the chosen role.
const stored = await page.evaluate(() => localStorage.getItem('cbpoa_role'));
expect(stored).toBe('doctor');
await page.reload();
// After reload the switcher reflects the persisted role.
await expect(page.locator(`[data-testid="${TESTIDS.perspectiveSwitcher}"]`)).toHaveValue('doctor');
});
});