"""Unit tests for the deterministic mastery engine (no DB, no API).""" from __future__ import annotations from datetime import date, datetime, timedelta, timezone from app.services.mastery_engine import ( EvidenceEvent, MasterySnapshot, apply_evidence, classify_error, compute_state, effective_strength, review_interval_days, ) NOW = datetime(2026, 7, 16, 10, 0, tzinfo=timezone.utc) EXAM = date(2026, 9, 30) def _event(**overrides) -> EvidenceEvent: base = dict(kind="checkpoint_mcq", correct=True, at=NOW, question_id="q1") base.update(overrides) return EvidenceEvent(**base) def test_stronger_evidence_moves_score_more() -> None: weak = MasterySnapshot() strong = MasterySnapshot() apply_evidence(weak, _event(kind="lesson_completed"), exam_date=EXAM, has_open_repair=False) apply_evidence(strong, _event(kind="checkpoint_numerical"), exam_date=EXAM, has_open_repair=False) assert strong.score > weak.score def test_hint_halves_the_strength_of_a_correct_answer() -> None: evidence: dict = {} without_hint = effective_strength(_event(hint_used=False), evidence) with_hint = effective_strength(_event(hint_used=True), evidence) assert with_hint == without_hint * 0.5 def test_repeating_a_memorized_question_earns_almost_nothing() -> None: snapshot = MasterySnapshot() apply_evidence(snapshot, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False) first_score = snapshot.score update = apply_evidence(snapshot, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False) # Second correct on the SAME question applies dampened strength. assert update.applied_strength < 0.2 fresh = MasterySnapshot() apply_evidence(fresh, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False) second_unique = apply_evidence(fresh, _event(question_id="q2"), exam_date=EXAM, has_open_repair=False) assert second_unique.applied_strength > update.applied_strength assert snapshot.score >= first_score # never punished for correct def test_wrong_answer_reduces_score_confidence_and_streak() -> None: snapshot = MasterySnapshot() apply_evidence(snapshot, _event(question_id="q1"), exam_date=EXAM, has_open_repair=False) apply_evidence(snapshot, _event(question_id="q2"), exam_date=EXAM, has_open_repair=False) score_before = snapshot.score confidence_before = snapshot.confidence update = apply_evidence( snapshot, _event(question_id="q3", correct=False, error_category="unit_error"), exam_date=EXAM, has_open_repair=True, ) assert snapshot.score < score_before assert snapshot.confidence < confidence_before assert update.consecutive_success == 0 assert update.after_state == "needs_repair" assert snapshot.evidence["error_categories"]["unit_error"] == 1 def test_secure_requires_score_confidence_and_consecutive_recalls() -> None: snapshot = MasterySnapshot() for question in ("q1", "q2", "q3", "q4", "q5"): update = apply_evidence( snapshot, _event(kind="checkpoint_numerical", question_id=question), exam_date=EXAM, has_open_repair=False, ) assert update.after_state == "secure" assert snapshot.score >= 75.0 assert update.consecutive_success >= 2 def test_state_priority_repair_beats_secure_numbers() -> None: state = compute_state( score=95.0, confidence=0.9, consecutive_success=5, attempts_count=8, has_check_evidence=True, has_open_repair=True, next_review_at=None, now=NOW, exam_date=EXAM, ) assert state == "needs_repair" def test_revision_due_and_at_risk_states() -> None: due = compute_state( score=80.0, confidence=0.7, consecutive_success=3, attempts_count=5, has_check_evidence=True, has_open_repair=False, next_review_at=NOW - timedelta(days=1), now=NOW, exam_date=EXAM, ) assert due == "revision_due" at_risk = compute_state( score=80.0, confidence=0.7, consecutive_success=3, attempts_count=5, has_check_evidence=True, has_open_repair=False, next_review_at=NOW - timedelta(days=6), now=NOW, exam_date=EXAM, ) assert at_risk == "at_risk" weak_near_exam = compute_state( score=40.0, confidence=0.4, consecutive_success=0, attempts_count=3, has_check_evidence=True, has_open_repair=False, next_review_at=NOW - timedelta(days=1), now=NOW, exam_date=NOW.date() + timedelta(days=7), ) assert weak_near_exam == "at_risk" def test_review_intervals_follow_documented_policy() -> None: assert review_interval_days("needs_repair", 0.9, 5) == 1 assert review_interval_days("developing", 0.5, 1) == 2 assert review_interval_days("secure", 0.5, 2) == 4 assert review_interval_days("secure", 0.8, 2) == 7 assert review_interval_days("secure", 0.8, 4) == 16 # 7 * 1.5^2, rounded assert review_interval_days("secure", 0.9, 10) == 21 # capped def test_review_never_scheduled_after_exam() -> None: snapshot = MasterySnapshot() close_exam = NOW.date() + timedelta(days=3) apply_evidence( snapshot, _event(kind="checkpoint_numerical"), exam_date=close_exam, has_open_repair=False, ) assert snapshot.next_review_at is not None assert snapshot.next_review_at.date() < close_exam def test_classify_error_categories() -> None: assert ( classify_error(question_type="mcq", student_answer="metre", correct_answer="hertz") == "concept_misunderstanding" ) assert ( classify_error(question_type="diagram", student_answer="x", correct_answer="y") == "diagram_interpretation" ) # Power-of-ten slip => unit conversion error assert ( classify_error( question_type="numerical", student_answer="The speed is 34000", correct_answer="340 m/s", ) == "unit_error" ) # No numbers at all => didn't reach a formula assert ( classify_error( question_type="numerical", student_answer="I am not sure how to start", correct_answer="340 m/s", ) == "formula_selection" ) # Short answer with some but not all rubric keywords assert ( classify_error( question_type="short", student_answer="It vibrates with maximum amplitude", correct_answer="Equal natural frequencies cause vibration with maximum amplitude", expected_keywords=["natural frequency", "maximum amplitude"], ) == "missing_exam_keyword" ) # No rubric keywords present at all assert ( classify_error( question_type="short", student_answer="sound is fast", correct_answer="Equal natural frequencies cause vibration with maximum amplitude", expected_keywords=["natural frequency", "maximum amplitude"], ) == "concept_misunderstanding" )