kr4phy's picture
Sync from GitHub
cff6ac7
Raw
History Blame Contribute Delete
12 kB
"""
FastAPI μ—”λ“œν¬μΈνŠΈ 톡합 ν…ŒμŠ€νŠΈ.
μ™ΈλΆ€ API (NEIS, ν–‰μ•ˆλΆ€) ν˜ΈμΆœμ„ mock ν•˜μ—¬ μ‹€μ œ 인터넷 μ—°κ²° 없이 μ‹€ν–‰ν•©λ‹ˆλ‹€.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pandas as pd
from fastapi.testclient import TestClient
from src.analytics.diagnostics import DiagnosticsResult
from src.analytics.predictor import ForecastResult
from src.analytics.scorer import SustainabilityScore
from src.analytics.xai import ShapResult
from src.api.main import app
client = TestClient(app)
# ── 곡톡 mock 데이터 ─────────────────────────────────────────────────────────
MOCK_SCHOOL_INFO_ROWS = [
{
"ATPT_OFCDC_SC_CODE": "P10",
"ATPT_OFCDC_SC_NM": "κ²½μƒλ‚¨λ„κ΅μœ‘μ²­",
"SD_SCHUL_CODE": "9290079",
"SCHUL_NM": "κ³€λͺ…μ΄ˆλ“±ν•™κ΅",
"SCHUL_KND_SC_NM": "μ΄ˆλ“±ν•™κ΅",
"LCTN_SC_NM": "λ†μ΄Œν˜•",
"FOND_SC_NM": "곡립",
"COEDU_SC_NM": "남여곡학",
"XTLN_YN": "N",
"SCHUL_FOND_YMD": "19620301",
}
]
MOCK_STUDENT_ROWS = [
{"SD_SCHUL_CODE": "9290079", "SCHUL_NM": "κ³€λͺ…μ΄ˆλ“±ν•™κ΅", "AY": "2023", "STUDENT_COUNT": "8", "SE_COUNT": "6"},
]
MOCK_TEACHER_ROWS = [
{"SD_SCHUL_CODE": "9290079", "SCHUL_NM": "κ³€λͺ…μ΄ˆλ“±ν•™κ΅", "AY": "2023", "TEACHER_COUNT": "4", "TEMP_TEACHER_COUNT": "2"},
]
MOCK_YEARLY_STUDENT_ROWS = [
[{"SD_SCHUL_CODE": "9290079", "AY": "2017", "STUDENT_COUNT": "20", "SE_COUNT": "6"}],
[{"SD_SCHUL_CODE": "9290079", "AY": "2018", "STUDENT_COUNT": "18", "SE_COUNT": "6"}],
[{"SD_SCHUL_CODE": "9290079", "AY": "2019", "STUDENT_COUNT": "16", "SE_COUNT": "6"}],
[{"SD_SCHUL_CODE": "9290079", "AY": "2020", "STUDENT_COUNT": "14", "SE_COUNT": "6"}],
[{"SD_SCHUL_CODE": "9290079", "AY": "2021", "STUDENT_COUNT": "12", "SE_COUNT": "6"}],
[{"SD_SCHUL_CODE": "9290079", "AY": "2022", "STUDENT_COUNT": "10", "SE_COUNT": "6"}],
[{"SD_SCHUL_CODE": "9290079", "AY": "2023", "STUDENT_COUNT": "8", "SE_COUNT": "6"}],
]
class TestHealthEndpoint:
def test_health_returns_ok(self) -> None:
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "env" in data
assert "granite_model" in data
class TestSchoolInfoEndpoint:
def test_school_not_found_returns_404(self) -> None:
with patch(
"src.api.routes.schools.DataGoKrClient.fetch_school_info",
new_callable=AsyncMock,
return_value=[],
):
response = client.get("/schools/NOTEXIST")
assert response.status_code == 404
def test_school_found_returns_info(self) -> None:
with patch(
"src.api.routes.schools.DataGoKrClient.fetch_school_info",
new_callable=AsyncMock,
return_value=MOCK_SCHOOL_INFO_ROWS,
):
response = client.get("/schools/9290079")
assert response.status_code == 200
data = response.json()
assert data["sd_schul_code"] == "9290079"
assert data["school_name"] == "κ³€λͺ…μ΄ˆλ“±ν•™κ΅"
class TestSchoolStatsEndpoint:
def test_stats_no_data_returns_404(self) -> None:
with (
patch(
"src.api.routes.schools.DataGoKrClient.fetch_student_count",
new_callable=AsyncMock,
return_value=[],
),
patch(
"src.api.routes.schools.DataGoKrClient.fetch_teacher_count",
new_callable=AsyncMock,
return_value=[],
),
):
response = client.get("/schools/NOTEXIST/stats?year=2023")
assert response.status_code == 404
def test_stats_returns_data(self) -> None:
with (
patch(
"src.api.routes.schools.DataGoKrClient.fetch_student_count",
new_callable=AsyncMock,
return_value=MOCK_STUDENT_ROWS,
),
patch(
"src.api.routes.schools.DataGoKrClient.fetch_teacher_count",
new_callable=AsyncMock,
return_value=MOCK_TEACHER_ROWS,
),
):
response = client.get("/schools/9290079/stats?year=2023")
assert response.status_code == 200
data = response.json()
assert data["year"] == 2023
assert float(data["student_count"]) == 8.0
class TestDemoEndpoint:
"""데λͺ¨ μ—”λ“œν¬μΈνŠΈ ν…ŒμŠ€νŠΈ (mock 데이터 λ°˜ν™˜)."""
def test_demo_diagnose_returns_200(self) -> None:
response = client.get("/demo/diagnose/9290079")
assert response.status_code == 200
def test_demo_diagnose_has_required_fields(self) -> None:
response = client.get("/demo/diagnose/9290079")
data = response.json()
required = {"schul_code", "school_name", "status_label", "sustainability_score", "forecast"}
assert required.issubset(data.keys())
def test_demo_forecast_years_sequential(self) -> None:
response = client.get("/demo/diagnose/9290079")
forecast = response.json()["forecast"]
years = forecast["forecast_years"]
for i in range(1, len(years)):
assert years[i] == years[i - 1] + 1
def test_demo_shap_has_top_factors(self) -> None:
response = client.get("/demo/diagnose/9290079")
shap = response.json()["shap_result"]
assert len(shap["top_factors"]) > 0
def test_demo_score_in_range(self) -> None:
response = client.get("/demo/diagnose/9290079")
score = response.json()["sustainability_score"]
assert 0 <= score <= 100
def test_demo_simulations_present(self) -> None:
response = client.get("/demo/diagnose/9290079")
sims = response.json()["simulations"]
assert len(sims) >= 1
class TestPredictionEndpoint:
def test_predict_returns_forecast(self) -> None:
fake_forecast = ForecastResult(
schul_code="9290079",
target_col="student_count",
forecast_years=[2024, 2025, 2026],
point_forecast=[7.0, 6.0, 5.0],
lower_bound=[6.0, 5.0, 4.0],
upper_bound=[8.0, 7.0, 6.0],
model_version="arima-fallback",
context_years=[2017, 2018, 2019, 2020, 2021, 2022, 2023],
context_values=[20.0, 18.0, 16.0, 14.0, 12.0, 10.0, 8.0],
)
with (
patch(
"src.api.routes.predictions.DataGoKrClient.fetch_student_count",
new_callable=AsyncMock,
side_effect=MOCK_YEARLY_STUDENT_ROWS,
),
patch(
"src.api.routes.predictions._predictor.predict",
return_value=fake_forecast,
),
):
response = client.get("/schools/9290079/predict?horizon_years=3&end_year=2023")
assert response.status_code == 200
data = response.json()
assert data["schul_code"] == "9290079"
assert len(data["forecast_years"]) == 3
class TestDiagnosticsAndSimulationEndpoint:
def test_diagnose_returns_integrated_payload(self) -> None:
fake_result = DiagnosticsResult(
schul_code="9290079",
school_name="κ³€λͺ…μ΄ˆλ“±ν•™κ΅",
status_label="μœ„ν—˜",
status_code=2,
sustainability_score=38.5,
forecast=ForecastResult(
schul_code="9290079",
target_col="student_count",
forecast_years=[2024, 2025],
point_forecast=[7.0, 6.0],
lower_bound=[6.0, 5.0],
upper_bound=[8.0, 7.0],
model_version="arima-fallback",
context_years=[2022, 2023],
context_values=[10.0, 8.0],
),
shap_result=ShapResult(
schul_code="9290079",
status_label="μœ„ν—˜",
status_code=2,
shap_values={"student_count_latest": 1.0},
top_factors=[
{
"feature": "student_count_latest",
"feature_kr": "ν˜„μž¬ 학생 수",
"shap_value": 1.0,
"feature_value": 8.0,
}
],
base_value=0.0,
model_accuracy=None,
),
score_detail=SustainabilityScore(
schul_code="9290079",
total_score=38.5,
curriculum_score=20.0,
personnel_score=30.0,
facility_score=40.0,
community_score=60.0,
sub_scores={},
warnings=["학생 μˆ˜κ°€ 맀우 μ μŠ΅λ‹ˆλ‹€."],
),
simulations={
"improve_teacher_ratio": {
"label": "ꡐ사 1인당 학생 수 κ°œμ„ ",
"description": "ꡐ원 증원",
"base_total_score": 38.5,
"sim_total_score": 41.0,
"delta_score": 2.5,
}
},
metadata={"diagnosed_at": "2026-01-01T00:00:00", "model_version": "arima-fallback"},
)
with (
patch(
"src.api.routes.diagnostics._collect_school_data",
new_callable=AsyncMock,
return_value=(
{"sd_schul_code": "9290079", "school_name": "κ³€λͺ…μ΄ˆλ“±ν•™κ΅"},
pd.Series({2022: 10.0, 2023: 8.0}),
),
),
patch(
"src.api.routes.diagnostics._engine.diagnose",
return_value=fake_result,
),
):
response = client.get("/schools/9290079/diagnose?horizon_years=2&end_year=2023")
assert response.status_code == 200
data = response.json()
assert data["schul_code"] == "9290079"
assert "forecast" in data
assert "shap_result" in data
assert "score_detail" in data
def test_simulate_returns_score(self) -> None:
with patch(
"src.api.routes.diagnostics._collect_school_data",
new_callable=AsyncMock,
return_value=(
{"sd_schul_code": "9290079", "teacher_count": 4, "student_count": 8},
pd.Series({2023: 8.0}),
),
):
response = client.post(
"/schools/9290079/simulate?end_year=2023",
json={"school_data_overrides": {"teacher_count": 6}},
)
assert response.status_code == 200
data = response.json()
assert data["schul_code"] == "9290079"
assert "total_score" in data
class TestRegionRiskEndpoint:
def test_region_risk_returns_summary(self) -> None:
with (
patch(
"src.api.routes.regions.DataGoKrClient.fetch_school_info",
new_callable=AsyncMock,
return_value=MOCK_SCHOOL_INFO_ROWS,
),
patch(
"src.api.routes.regions.DataGoKrClient.fetch_student_count",
new_callable=AsyncMock,
return_value=MOCK_STUDENT_ROWS,
),
patch(
"src.api.routes.regions.DataGoKrClient.fetch_teacher_count",
new_callable=AsyncMock,
return_value=MOCK_TEACHER_ROWS,
),
):
response = client.get("/regions/P10/risk?year=2023")
assert response.status_code == 200
data = response.json()
assert data["region_code"] == "P10"
assert "schools" in data
assert "summary" in data