feat: Phase 2 — leadership 大屏 (/overview) + district normalization + drawer a11y
Phase 2 of the UX modernization. Three conflict-free workstreams. Leadership 驾驶舱 (/overview): - Wuhan 13-district Leaflet choropleth (public/wuhan_districts.geojson, keyed on name, darker=higher per 高风险高亮), legend, hover/click-zoom - 全部/门诊/住院 Segmented toggle drives choropleth + Top-5 district bar - literal "数据截至2023-12" as-of badge (D3 honesty); raw spinner → LoadingState - decompose OverviewDashboard 501→273; 6 components + 2 helpers under components/overview/ District normalization (backend data boundary): - case_loader.normalize_district + load_cases_by_district_daily collapse the 26 dirty labels (武昌/武昌区…) → 13 canonical; analysis/grid/insights repointed (fixes a grid-merge row-drop bug as a bonus); in-memory, schema unchanged Shell a11y (code-review carryover): - drawer is now a proper modal: ESC, body scroll-lock, focus-in + focus-trap cycle + focus-restore, role=dialog/aria-modal/aria-label, hamburger aria-expanded - SideNav expanded state lifted to AppShell so rail+drawer stay in sync - RouteErrorBoundary around <Outlet/> keeps shell chrome on page/chunk failure Gates: tsc 0 · vitest 64 · e2e 19/19 (17 user-flows + 2 overview) · build ok · backend pytest 6 new + 48 regression green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
82
backend/tests/test_district_normalization.py
Normal file
82
backend/tests/test_district_normalization.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests for district label normalization at the case-loader boundary.
|
||||
|
||||
The processed/cases_by_district_daily.parquet carries both bare ("武昌") and
|
||||
区-suffixed ("武昌区") spellings of each district (26 labels = 13 districts × 2
|
||||
spellings), which double-counts in any roll-up. data.case_loader normalizes
|
||||
these to the canonical 13 区-suffixed names and re-aggregates. These tests pin
|
||||
that behavior.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
# Ensure the backend package root is importable at collection time (mirrors the
|
||||
# sys.path handling other modules rely on once the app is imported).
|
||||
BACKEND_ROOT = Path(__file__).parent.parent
|
||||
if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
from data.case_loader import ( # noqa: E402
|
||||
CANONICAL_DISTRICTS,
|
||||
normalize_district,
|
||||
load_cases_by_district_daily,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
RAW_PARQUET = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||
|
||||
|
||||
def test_normalize_district_known_bare_forms():
|
||||
"""Every known bare form maps to its canonical 区-suffixed name."""
|
||||
cases = {
|
||||
"武昌": "武昌区", "汉阳": "汉阳区", "江岸": "江岸区", "硚口": "硚口区",
|
||||
"青山": "青山区", "洪山": "洪山区", "东西湖": "东西湖区", "汉南": "汉南区",
|
||||
"蔡甸": "蔡甸区", "江夏": "江夏区", "黄陂": "黄陂区", "新洲": "新洲区",
|
||||
"江汉": "江汉区",
|
||||
}
|
||||
for bare, canonical in cases.items():
|
||||
assert normalize_district(bare) == canonical
|
||||
|
||||
|
||||
def test_normalize_district_already_suffixed_passes_through():
|
||||
for d in CANONICAL_DISTRICTS:
|
||||
assert normalize_district(d) == d
|
||||
|
||||
|
||||
def test_canonical_set_is_exactly_thirteen():
|
||||
assert len(CANONICAL_DISTRICTS) == 13
|
||||
assert len(set(CANONICAL_DISTRICTS)) == 13
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
|
||||
def test_loader_collapses_to_thirteen_canonical_districts():
|
||||
df = load_cases_by_district_daily()
|
||||
districts = set(df["district"].unique())
|
||||
|
||||
# (a) exactly 13 unique districts, all canonical
|
||||
assert len(districts) == 13, f"expected 13 districts, got {len(districts)}: {sorted(districts)}"
|
||||
assert districts == set(CANONICAL_DISTRICTS)
|
||||
|
||||
# (b) no bare / unsuffixed duplicates remain
|
||||
for name in districts:
|
||||
assert name.endswith(("区", "县", "市")), f"unsuffixed district leaked: {name}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
|
||||
def test_loader_preserves_totals_no_rows_dropped_or_double_counted():
|
||||
"""Sum integrity: normalized total == raw parquet total."""
|
||||
raw = pd.read_parquet(RAW_PARQUET)
|
||||
normalized = load_cases_by_district_daily()
|
||||
|
||||
assert int(normalized["total_cases"].sum()) == int(raw["total_cases"].sum())
|
||||
assert int(normalized["outpatient_count"].sum()) == int(raw["outpatient_count"].sum())
|
||||
assert int(normalized["inpatient_count"].sum()) == int(raw["inpatient_count"].sum())
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RAW_PARQUET.exists(), reason="case parquet not present")
|
||||
def test_raw_parquet_actually_has_dirty_labels():
|
||||
"""Sanity: the raw file really has the 26-label problem we are fixing."""
|
||||
raw = pd.read_parquet(RAW_PARQUET)
|
||||
assert raw["district"].nunique() > 13
|
||||
Reference in New Issue
Block a user