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>
79 lines
3.5 KiB
TypeScript
79 lines
3.5 KiB
TypeScript
import { test, expect, Page } from '@playwright/test';
|
||
import { TESTIDS } from '../src/utils/testids';
|
||
|
||
// 与 user-flows.spec.ts 一致的鉴权策略:注入 token 绕过登录门,并 mock /api/**,
|
||
// 让用例脱离活的后端 hermetic 运行。
|
||
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('/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('/streets')) {
|
||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ streets: [] }) });
|
||
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: [], trend: [], total: 0 }) });
|
||
return;
|
||
}
|
||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], items: [], total: 0 }) });
|
||
});
|
||
}
|
||
|
||
// 粒度(granularity)以 URL query 参数为真相来源(source of truth)。
|
||
// 监测页通过 useSearchParams 读取它,drilldownStore 单向派生。
|
||
test.describe('monitoring granularity URL source-of-truth', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await seedAuthAndMockApi(page);
|
||
});
|
||
|
||
test('deep-link granularity=street mounts page and reflects street in control', async ({ page }) => {
|
||
await page.goto('/monitoring?granularity=street');
|
||
|
||
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||
|
||
const control = page.getByTestId(TESTIDS.granularityControl);
|
||
await expect(control).toBeVisible();
|
||
// 街道分段为激活态(Segmented 给激活按钮加 bg-primary)。
|
||
const streetBtn = page.getByTestId(`${TESTIDS.granularityControl}-street`);
|
||
await expect(streetBtn).toHaveClass(/bg-primary/);
|
||
});
|
||
|
||
test('clicking a granularity control updates the URL granularity param', async ({ page }) => {
|
||
await page.goto('/monitoring?granularity=street');
|
||
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||
|
||
// 切到「区域」应把 URL 写为 granularity=district。
|
||
await page.getByTestId(`${TESTIDS.granularityControl}-district`).click();
|
||
await expect(page).toHaveURL(/granularity=district/);
|
||
|
||
// 切到「全市」应把 URL 写为 granularity=city。
|
||
await page.getByTestId(`${TESTIDS.granularityControl}-city`).click();
|
||
await expect(page).toHaveURL(/granularity=city/);
|
||
});
|
||
|
||
test('deep-link granularity=district survives a reload', async ({ page }) => {
|
||
await page.goto('/monitoring?granularity=district');
|
||
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||
await expect(page).toHaveURL(/granularity=district/);
|
||
|
||
await page.reload();
|
||
await expect(page.getByTestId(TESTIDS.pageMonitoring)).toBeVisible();
|
||
await expect(page).toHaveURL(/granularity=district/);
|
||
await expect(page.getByTestId(`${TESTIDS.granularityControl}-district`)).toHaveClass(/bg-primary/);
|
||
});
|
||
});
|