Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

249 lines
9.1 KiB
Python

"""Tests for the grading module and the unified post-answer pipeline.
``grade_answer`` is the pure correctness check. ``classify_error`` is the
coarse wrong-answer tagger. ``LearningService.grade_and_record`` folds both of
those through the full record -> mastery -> spaced-repetition pipeline and is
fail-closed: with no stored expected answer the attempt is recorded wrong.
"""
from deeptutor.learning.grading import classify_error, grade_answer
from deeptutor.learning.models import (
ErrorType,
KnowledgePoint,
KnowledgeType,
LearningModule,
LearningProgress,
)
from deeptutor.learning.scheduler import SpacedRepetitionScheduler
from deeptutor.learning.service import LearningService
from deeptutor.learning.storage import LearningStore
class TestChoiceGrading:
def test_choice_exact_match(self):
assert grade_answer("A", "A", "choice") is True
def test_choice_case_insensitive(self):
assert grade_answer("b", "B", "choice") is True
def test_choice_with_spaces(self):
assert grade_answer("A ", " A", "choice") is True
def test_choice_wrong(self):
assert grade_answer("C", "A", "choice") is False
class TestShortGrading:
def test_short_exact_match(self):
assert grade_answer("photosynthesis", "photosynthesis", "short") is True
def test_short_fuzzy_pass(self):
# "photosynthesi" vs "photosynthesis" — high similarity
assert grade_answer("photosynthesi", "photosynthesis", "short") is True
def test_short_fuzzy_fail(self):
assert grade_answer("completely different", "photosynthesis", "short") is False
def test_short_long_expected_no_fuzzy(self):
long_expected = "a" * 31 # >30 chars, no fuzzy
assert grade_answer(long_expected, long_expected, "short") is True
assert grade_answer("something else entirely", long_expected, "short") is False
class TestOpenGrading:
def test_open_keywords_pass(self):
expected = "cell membrane, nucleus, mitochondria"
user = "The cell has a cell membrane and nucleus, with mitochondria for energy"
assert grade_answer(user, expected, "open") is True
def test_open_keywords_fail(self):
expected = "cell membrane, nucleus, mitochondria"
user = "I don't know anything about cells"
assert grade_answer(user, expected, "open") is False
def test_open_chinese_separators(self):
expected = "光合作用;叶绿体;二氧化碳"
user = "光合作用发生在叶绿体中,需要二氧化碳"
assert grade_answer(user, expected, "open") is True
class TestEdgeCases:
def test_empty_expected_returns_false(self):
assert grade_answer("anything", "", "short") is False
assert grade_answer("anything", " ", "short") is False
def test_empty_user_answer(self):
assert grade_answer("", "expected", "short") is False
def test_substring_no_longer_matches(self):
"""Regression: 'expected in user' substring match must not cause false positive."""
user = "I do not know electromagnetic induction but maybe something else"
expected = "electromagnetic induction"
assert grade_answer(user, expected, "short") is False
def test_unknown_type_returns_false(self):
assert grade_answer("a", "a", "unknown") is False
class TestClassifyError:
"""Coarse wrong-answer tagging used by the post-answer pipeline.
Blank means "I didn't know" (metacognitive); anything else is treated as a
wrong application. The richer taxonomy is assigned later by the LLM.
"""
def test_blank_answer_is_metacognitive(self):
assert classify_error("") is ErrorType.METACOGNITIVE
def test_whitespace_only_answer_is_metacognitive(self):
assert classify_error(" \n\t ") is ErrorType.METACOGNITIVE
def test_nonblank_answer_is_application_error(self):
assert classify_error("the answer is 42") is ErrorType.APPLICATION_ERROR
def _progress_with_kp(kp_type: KnowledgeType = KnowledgeType.CONCEPT) -> LearningProgress:
progress = LearningProgress(book_id="book1")
progress.modules = [
LearningModule(
id="m1",
name="Module 1",
order=0,
knowledge_points=[KnowledgePoint(id="kp1", name="KP1", type=kp_type, module_id="m1")],
)
]
progress.knowledge_types["kp1"] = kp_type
return progress
class TestGradeAndRecordFailClosed:
"""``grade_and_record`` is the single post-answer pipeline. It must never
grade an answer correct when there is no stored expected answer, and it must
fold correctness through attempt history, mastery, and error records."""
def test_no_expected_answer_is_wrong_and_records_error(self, tmp_path):
service = LearningService(LearningStore(root=tmp_path))
progress = _progress_with_kp()
result = service.grade_and_record(
progress,
question_id="q1",
knowledge_point_id="kp1",
module_id="m1",
user_answer="anything plausible",
expected_answer="", # missing expected -> fail-closed
)
assert result is False
assert len(progress.quiz_attempts) == 1
attempt = progress.quiz_attempts[0]
assert attempt.is_correct is False
# Non-blank wrong answer is an application error and opens an error record.
assert attempt.error_type is ErrorType.APPLICATION_ERROR
assert len(progress.error_records) == 1
assert progress.error_records[0].status == "active"
assert progress.error_records[0].error_type is ErrorType.APPLICATION_ERROR
def test_blank_wrong_answer_is_metacognitive(self, tmp_path):
service = LearningService(LearningStore(root=tmp_path))
progress = _progress_with_kp()
result = service.grade_and_record(
progress,
question_id="q1",
knowledge_point_id="kp1",
module_id="m1",
user_answer="",
expected_answer="photosynthesis",
)
assert result is False
assert progress.quiz_attempts[0].error_type is ErrorType.METACOGNITIVE
def test_correct_answer_records_and_caps_single_attempt_mastery(self, tmp_path):
service = LearningService(LearningStore(root=tmp_path))
progress = _progress_with_kp()
result = service.grade_and_record(
progress,
question_id="q1",
knowledge_point_id="kp1",
module_id="m1",
user_answer="photosynthesis",
expected_answer="photosynthesis",
)
assert result is True
assert progress.quiz_attempts[0].is_correct is True
assert progress.quiz_attempts[0].error_type is None
# A single correct attempt is capped at low confidence (0.5), never 1.0.
assert progress.mastery_levels["kp1"] == 0.5
assert progress.error_records == []
def test_correct_answer_graduates_existing_error_record(self, tmp_path):
service = LearningService(LearningStore(root=tmp_path))
progress = _progress_with_kp()
# First a wrong answer opens an error record...
service.grade_and_record(
progress,
question_id="q1",
knowledge_point_id="kp1",
module_id="m1",
user_answer="wrong guess",
expected_answer="photosynthesis",
)
assert progress.error_records[0].status == "active"
# ...then a correct retry graduates it.
service.grade_and_record(
progress,
question_id="q1",
knowledge_point_id="kp1",
module_id="m1",
user_answer="photosynthesis",
expected_answer="photosynthesis",
)
assert progress.error_records[0].status == "graduated"
def test_persists_through_store(self, tmp_path):
store = LearningStore(root=tmp_path)
service = LearningService(store)
progress = _progress_with_kp()
service.grade_and_record(
progress,
question_id="q1",
knowledge_point_id="kp1",
module_id="m1",
user_answer="photosynthesis",
expected_answer="photosynthesis",
)
loaded = store.load("book1")
assert loaded is not None
assert len(loaded.quiz_attempts) == 1
assert loaded.mastery_levels["kp1"] == 0.5
def test_scheduler_advances_repetition_and_builds_queue(self, tmp_path):
store = LearningStore(root=tmp_path)
service = LearningService(store)
scheduler = SpacedRepetitionScheduler()
progress = _progress_with_kp(KnowledgeType.CONCEPT)
service.grade_and_record(
progress,
question_id="q1",
knowledge_point_id="kp1",
module_id="m1",
user_answer="photosynthesis",
expected_answer="photosynthesis",
scheduler=scheduler,
)
# A repetition state was created and the review queue rebuilt from it.
assert "kp1" in progress.repetition_states
assert len(progress.review_queue) == 1
assert progress.review_queue[0].knowledge_point_id == "kp1"