| """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) |
| |
| 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 |
|
|
|
|
| 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 |
| assert review_interval_days("secure", 0.9, 10) == 21 |
|
|
|
|
| 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" |
| ) |
| |
| assert ( |
| classify_error( |
| question_type="numerical", |
| student_answer="The speed is 34000", |
| correct_answer="340 m/s", |
| ) |
| == "unit_error" |
| ) |
| |
| assert ( |
| classify_error( |
| question_type="numerical", |
| student_answer="I am not sure how to start", |
| correct_answer="340 m/s", |
| ) |
| == "formula_selection" |
| ) |
| |
| 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" |
| ) |
| |
| 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" |
| ) |
|
|