"""eid 약점 판정/포맷 순수 함수 테스트 (stdlib only, venv 불필요). W3-2. 실행: python3 tests/eid/test_weakness_compute.py (또는 pytest) """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "app")) from services.study.weakness_compute import ( # noqa: E402 decide_tier, format_habit_block, format_weakness_block, overall_trend, topic_trend, ) # worker 임계 미러 (테스트 고정값) TH = dict(min_attempts=5, chronic_focus=3, relapse_focus=2, review_overdue=5) def test_decide_tier_focus_on_chronic(): assert decide_tier(chronic=3, relapsed=0, overdue=0, unsure=0, attempted=20, **TH) == "focus" def test_decide_tier_focus_on_relapse(): assert decide_tier(chronic=0, relapsed=2, overdue=0, unsure=0, attempted=20, **TH) == "focus" def test_decide_tier_review_on_single_chronic(): assert decide_tier(chronic=1, relapsed=0, overdue=0, unsure=0, attempted=20, **TH) == "review" def test_decide_tier_review_on_overdue(): assert decide_tier(chronic=0, relapsed=0, overdue=5, unsure=0, attempted=20, **TH) == "review" def test_decide_tier_shallow_caps_to_watch(): # 표본 미달(attempted<5) → chronic 많아도 focus/review 단정 안 함, watch 상한 (conservative) assert decide_tier(chronic=4, relapsed=3, overdue=9, unsure=0, attempted=3, **TH) == "watch" def test_decide_tier_watch_on_unsure(): assert decide_tier(chronic=0, relapsed=0, overdue=0, unsure=2, attempted=10, **TH) == "watch" def test_decide_tier_none_when_clean(): assert decide_tier(chronic=0, relapsed=0, overdue=0, unsure=0, attempted=20, **TH) is None def test_topic_trend(): assert topic_trend([]) == "정체" assert topic_trend([{"newly_correct": 10, "relapsed": 1, "chronic_remaining": 1}]) == "개선" assert topic_trend([{"newly_correct": 1, "relapsed": 5, "chronic_remaining": 4}]) == "악화" assert topic_trend([{"newly_correct": 3, "relapsed": 2, "chronic_remaining": 1}]) == "정체" def test_overall_trend_majority(): assert overall_trend([]) == "정체" assert overall_trend(["악화", "악화", "개선"]) == "악화" assert overall_trend(["개선", "개선", "악화"]) == "개선" assert overall_trend(["개선", "악화"]) == "정체" # 동률 def test_format_weakness_block_empty_guards(): out = format_weakness_block([], shallow_overall=False) assert "약점으로 판정된 토픽 없음" in out assert "추정하지 마라" in out # 환각 약점 차단 문구 def test_format_weakness_block_content_and_shallow(): ws = [{"topic": "가스설비", "chronic": 4, "relapsed": 1, "unsure": 2, "coverage_gap": 7, "overdue": 3, "trend": "악화", "tier": "focus"}] out = format_weakness_block(ws, shallow_overall=True) assert "가스설비" in out and "tier=focus" in out and "추세 악화" in out assert "표본 적음" in out # shallow 주석 def test_format_habit_block(): out = format_habit_block({ "avoidance_topics": ["배관", "연소"], "session_abandon_rate": 0.25, "stale_due_count": 12, "skew_topic": "배관", }) assert "배관" in out and "25%" in out and "12건" in out and "편중" in out def _run(): fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] fails = 0 for fn in fns: try: fn(); print(f" PASS {fn.__name__}") except Exception as exc: # noqa: BLE001 fails += 1; print(f" FAIL {fn.__name__}: {type(exc).__name__}: {exc}") print(f"\n{len(fns) - fails}/{len(fns)} passed") return 1 if fails else 0 if __name__ == "__main__": raise SystemExit(_run())