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>
83 lines
3.3 KiB
Python
83 lines
3.3 KiB
Python
"""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
|