e1da984e08
문제 SR과 카드 SR이 같은 간격 상수·산술을 참조하도록 순수함수 추출. 운영 동작 무변경.
- app/services/study/sr_schedule.py: REVIEW_INTERVAL_DAYS{1:3,2:7,3:14}/MASTERED=4/FIRST_DUE=1
+ advance(stage,outcome,now)→(new_stage,new_due) | None(skipped) + first_due(now).
진입 게이트(due_at IS NOT NULL/최초 due/skipped 불변)는 호출부 잔류(finalize vs review-complete 정책 차이).
- session_finalize.py: 상수·advance 분기 → sr_schedule import + sr_advance() (re-export 유지).
- study_question_progress.py: DEFAULT_FIRST_DUE_DAYS → sr_schedule import.
- 회귀 테스트 7/7: 전진 1·3·7·14·졸업·리셋·skipped불변·상수 + 전 stage×outcome 구 로직 바이트 동등.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
94 lines
3.3 KiB
Python
94 lines
3.3 KiB
Python
"""sr_schedule 공용추출 회귀 테스트 (B1).
|
||
|
||
문제 SR 동작이 추출 전과 동일함을 보장 — advance() 가 구 session_finalize 분기 로직과
|
||
바이트 동등(전진 1·3·7·14 / 졸업 / 오답 리셋 / skipped 불변 / 상수값)인지 검증.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
sys.path.insert(0, str(ROOT / "app"))
|
||
|
||
from services.study import sr_schedule as sr # noqa: E402
|
||
|
||
NOW = datetime(2026, 6, 7, 12, 0, 0, tzinfo=timezone.utc)
|
||
|
||
|
||
def _old_logic(review_stage, outcome, now):
|
||
"""추출 전 session_finalize.py:188-201 의 산술을 그대로 재현 (동등성 기준)."""
|
||
if outcome == "correct":
|
||
new_stage = (review_stage or 0) + 1
|
||
if new_stage >= 4:
|
||
return new_stage, None
|
||
return new_stage, now + timedelta(days={1: 3, 2: 7, 3: 14}[new_stage])
|
||
elif outcome in ("wrong", "unsure"):
|
||
return 0, now + timedelta(days=1)
|
||
return None # skipped
|
||
|
||
|
||
def test_constants():
|
||
assert sr.REVIEW_INTERVAL_DAYS == {1: 3, 2: 7, 3: 14}
|
||
assert sr.REVIEW_STAGE_MASTERED == 4
|
||
assert sr.DEFAULT_FIRST_DUE_DAYS == 1
|
||
|
||
|
||
def test_advance_correct_progression():
|
||
assert sr.advance(None, "correct", NOW) == (1, NOW + timedelta(days=3))
|
||
assert sr.advance(0, "correct", NOW) == (1, NOW + timedelta(days=3))
|
||
assert sr.advance(1, "correct", NOW) == (2, NOW + timedelta(days=7))
|
||
assert sr.advance(2, "correct", NOW) == (3, NOW + timedelta(days=14))
|
||
|
||
|
||
def test_advance_graduation():
|
||
# stage 3 → correct → stage 4 = 졸업(due_at=None)
|
||
assert sr.advance(3, "correct", NOW) == (4, None)
|
||
assert sr.advance(4, "correct", NOW) == (5, None)
|
||
|
||
|
||
def test_advance_reset():
|
||
assert sr.advance(0, "wrong", NOW) == (0, NOW + timedelta(days=1))
|
||
assert sr.advance(2, "wrong", NOW) == (0, NOW + timedelta(days=1))
|
||
assert sr.advance(2, "unsure", NOW) == (0, NOW + timedelta(days=1))
|
||
|
||
|
||
def test_advance_skipped_no_change():
|
||
assert sr.advance(1, "skipped", NOW) is None
|
||
assert sr.advance(3, "skipped", NOW) is None
|
||
|
||
|
||
def test_first_due():
|
||
assert sr.first_due(NOW) == (0, NOW + timedelta(days=1))
|
||
|
||
|
||
def test_equivalence_with_old_logic():
|
||
# 전 stage × 전 outcome 조합에서 추출 함수 == 구 로직.
|
||
for stage in (None, 0, 1, 2, 3, 4):
|
||
for outcome in ("correct", "wrong", "unsure", "skipped"):
|
||
assert sr.advance(stage, outcome, NOW) == _old_logic(stage, outcome, NOW), \
|
||
f"mismatch stage={stage} outcome={outcome}"
|
||
|
||
|
||
def test_reexport_preserved():
|
||
# 기존 import 경로 (session_finalize / study_question_progress) 가 상수를 재-export.
|
||
from services.study import session_finalize as sf
|
||
assert sf.REVIEW_INTERVAL_DAYS == sr.REVIEW_INTERVAL_DAYS
|
||
assert sf.REVIEW_STAGE_MASTERED == sr.REVIEW_STAGE_MASTERED
|
||
assert sf.DEFAULT_FIRST_DUE_DAYS == sr.DEFAULT_FIRST_DUE_DAYS
|
||
|
||
|
||
_TESTS = [v for k, v in dict(globals()).items() if k.startswith("test_")]
|
||
|
||
if __name__ == "__main__":
|
||
# session_finalize import 는 무거운 의존(ai 등) 가능 — reexport 테스트만 조건부.
|
||
ran = 0
|
||
for t in _TESTS:
|
||
if t.__name__ == "test_reexport_preserved":
|
||
continue
|
||
t()
|
||
ran += 1
|
||
print(f"OK ({ran} pure tests; reexport는 pytest에서)")
|