| """ |
| 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_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 |
|
|