279 lines
9.3 KiB
Python
279 lines
9.3 KiB
Python
|
|
# retoor <retoor@molodetz.nl>
|
||
|
|
|
||
|
|
import json
|
||
|
|
from datetime import datetime, timedelta, timezone
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from devplacepy.services.quiz import scoring
|
||
|
|
|
||
|
|
OPTIONS = [
|
||
|
|
{"uid": "o0", "position": 0, "label": "L0", "match_value": "M0", "is_correct": 1},
|
||
|
|
{"uid": "o1", "position": 1, "label": "L1", "match_value": "M1", "is_correct": 1},
|
||
|
|
{"uid": "o2", "position": 2, "label": "L2", "match_value": "M2", "is_correct": 0},
|
||
|
|
{"uid": "o3", "position": 3, "label": "L3", "match_value": "M3", "is_correct": 0},
|
||
|
|
]
|
||
|
|
|
||
|
|
NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||
|
|
|
||
|
|
|
||
|
|
def question(kind, **extra):
|
||
|
|
base = {
|
||
|
|
"uid": "q1",
|
||
|
|
"kind": kind,
|
||
|
|
"points": 3,
|
||
|
|
"correct_boolean": 0,
|
||
|
|
"numeric_value": 0.0,
|
||
|
|
"numeric_tolerance": 0.0,
|
||
|
|
"case_sensitive": 0,
|
||
|
|
}
|
||
|
|
base.update(extra)
|
||
|
|
return base
|
||
|
|
|
||
|
|
|
||
|
|
def test_question_kinds_are_the_eight_documented_keys():
|
||
|
|
assert scoring.KIND_KEYS == (
|
||
|
|
"single_choice",
|
||
|
|
"multiple_choice",
|
||
|
|
"true_false",
|
||
|
|
"free_text",
|
||
|
|
"fill_blank",
|
||
|
|
"numeric",
|
||
|
|
"ordering",
|
||
|
|
"matching",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_only_free_text_is_ai_graded():
|
||
|
|
ai = [kind.key for kind in scoring.QUESTION_KINDS if kind.graded_by == "ai"]
|
||
|
|
assert ai == ["free_text"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_awarded_points_is_monotonic_and_bounded():
|
||
|
|
for points in (1, 7, 100):
|
||
|
|
previous = -1.0
|
||
|
|
for step in range(0, 201):
|
||
|
|
awarded = scoring.awarded_points(points, step / 200.0)
|
||
|
|
assert 0.0 <= awarded <= points
|
||
|
|
assert awarded >= previous
|
||
|
|
previous = awarded
|
||
|
|
|
||
|
|
|
||
|
|
def test_awarded_points_clamps_outside_the_unit_interval():
|
||
|
|
assert scoring.awarded_points(5, 4.0) == 5
|
||
|
|
assert scoring.awarded_points(5, -4.0) == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_score_percent_stays_within_bounds():
|
||
|
|
for max_points in (0, 1, 14, 199):
|
||
|
|
for score_points in (0, 1, 14, 400):
|
||
|
|
assert 0.0 <= scoring.score_percent(score_points, max_points) <= 100.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_score_percent_is_zero_without_max_points():
|
||
|
|
assert scoring.score_percent(10, 0) == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_score_percent_is_exactly_full_at_max():
|
||
|
|
assert scoring.score_percent(14, 14) == 100.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_passed_needs_a_pass_mark():
|
||
|
|
assert scoring.passed(100.0, 0) is False
|
||
|
|
assert scoring.passed(70.0, 70) is True
|
||
|
|
assert scoring.passed(69.9, 70) is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_single_choice_grades_the_correct_option():
|
||
|
|
result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["o0"]})
|
||
|
|
assert result.score == 1.0 and result.is_correct and result.graded_by == "auto"
|
||
|
|
|
||
|
|
|
||
|
|
def test_single_choice_rejects_more_than_one_pick():
|
||
|
|
result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["o0", "o1"]})
|
||
|
|
assert result.score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_single_choice_rejects_an_unknown_option():
|
||
|
|
result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["nope"]})
|
||
|
|
assert result.score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_multiple_choice_gives_partial_credit():
|
||
|
|
result = scoring.grade_answer(question("multiple_choice"), OPTIONS, {"option_uids": ["o0"]})
|
||
|
|
assert result.score == 0.5 and not result.is_correct
|
||
|
|
|
||
|
|
|
||
|
|
def test_multiple_choice_penalises_wrong_picks_but_never_below_zero():
|
||
|
|
result = scoring.grade_answer(
|
||
|
|
question("multiple_choice"), OPTIONS, {"option_uids": ["o2", "o3"]}
|
||
|
|
)
|
||
|
|
assert result.score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_multiple_choice_is_correct_only_on_the_exact_set():
|
||
|
|
result = scoring.grade_answer(
|
||
|
|
question("multiple_choice"), OPTIONS, {"option_uids": ["o0", "o1"]}
|
||
|
|
)
|
||
|
|
assert result.score == 1.0 and result.is_correct
|
||
|
|
|
||
|
|
|
||
|
|
def test_multiple_choice_ignores_duplicate_picks():
|
||
|
|
result = scoring.grade_answer(
|
||
|
|
question("multiple_choice"), OPTIONS, {"option_uids": ["o0", "o0", "o1"]}
|
||
|
|
)
|
||
|
|
assert result.score == 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_true_false_matches_the_stored_boolean():
|
||
|
|
prompt = question("true_false", correct_boolean=1)
|
||
|
|
assert scoring.grade_answer(prompt, [], {"answer_text": "true"}).score == 1.0
|
||
|
|
assert scoring.grade_answer(prompt, [], {"answer_text": "false"}).score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_true_false_without_an_answer_scores_zero():
|
||
|
|
assert scoring.grade_answer(question("true_false"), [], {"answer_text": ""}).score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_free_text_raises_for_the_ai_grader():
|
||
|
|
with pytest.raises(scoring.NeedsAiGrading):
|
||
|
|
scoring.grade_answer(question("free_text"), [], {"answer_text": "anything"})
|
||
|
|
|
||
|
|
|
||
|
|
def test_numeric_accepts_within_tolerance():
|
||
|
|
prompt = question("numeric", numeric_value=10.0, numeric_tolerance=0.5)
|
||
|
|
assert scoring.grade_answer(prompt, [], {"answer_text": "10.4"}).score == 1.0
|
||
|
|
assert scoring.grade_answer(prompt, [], {"answer_text": "10.6"}).score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_numeric_accepts_a_comma_decimal_separator():
|
||
|
|
prompt = question("numeric", numeric_value=1.5, numeric_tolerance=0.0)
|
||
|
|
assert scoring.grade_answer(prompt, [], {"answer_text": "1,5"}).score == 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_numeric_rejects_a_non_number():
|
||
|
|
prompt = question("numeric", numeric_value=1.0)
|
||
|
|
assert scoring.grade_answer(prompt, [], {"answer_text": "ten"}).score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_fill_blank_scores_per_blank():
|
||
|
|
prompt = question("fill_blank")
|
||
|
|
payload = json.dumps(["M0", "wrong", "M2", "M3"])
|
||
|
|
result = scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload})
|
||
|
|
assert result.score == 0.75
|
||
|
|
|
||
|
|
|
||
|
|
def test_fill_blank_normalizes_whitespace_and_case_by_default():
|
||
|
|
prompt = question("fill_blank")
|
||
|
|
payload = json.dumps([" m0 ", "M1", "M2", "M3"])
|
||
|
|
assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_fill_blank_honours_case_sensitivity():
|
||
|
|
prompt = question("fill_blank", case_sensitive=1)
|
||
|
|
payload = json.dumps(["m0", "M1", "M2", "M3"])
|
||
|
|
assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 0.75
|
||
|
|
|
||
|
|
|
||
|
|
def test_ordering_scores_the_longest_correct_prefix():
|
||
|
|
prompt = question("ordering")
|
||
|
|
order = ["o0", "o1", "o3", "o2"]
|
||
|
|
assert scoring.grade_answer(prompt, OPTIONS, {"option_uids": order}).score == 0.5
|
||
|
|
|
||
|
|
|
||
|
|
def test_ordering_is_correct_on_the_exact_sequence():
|
||
|
|
prompt = question("ordering")
|
||
|
|
order = ["o0", "o1", "o2", "o3"]
|
||
|
|
result = scoring.grade_answer(prompt, OPTIONS, {"option_uids": order})
|
||
|
|
assert result.score == 1.0 and result.is_correct
|
||
|
|
|
||
|
|
|
||
|
|
def test_matching_scores_per_pair():
|
||
|
|
prompt = question("matching")
|
||
|
|
payload = json.dumps({"o0": "M0", "o1": "M1", "o2": "wrong", "o3": "wrong"})
|
||
|
|
assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 0.5
|
||
|
|
|
||
|
|
|
||
|
|
def test_every_kind_survives_a_malformed_submission():
|
||
|
|
malformed = [{}, {"answer_text": None, "option_uids": None}, {"answer_text": "junk"}]
|
||
|
|
for kind in scoring.KIND_KEYS:
|
||
|
|
if kind == "free_text":
|
||
|
|
continue
|
||
|
|
for submission in malformed:
|
||
|
|
result = scoring.grade_answer(question(kind), OPTIONS, submission)
|
||
|
|
assert 0.0 <= result.score <= 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unknown_kind_scores_zero_instead_of_raising():
|
||
|
|
assert scoring.grade_answer(question("telepathy"), OPTIONS, {}).score == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_question_order_is_a_stable_permutation():
|
||
|
|
uids = [f"u{index}" for index in range(9)]
|
||
|
|
order = scoring.question_order(uids, True, "attempt-1")
|
||
|
|
assert sorted(order) == sorted(uids)
|
||
|
|
assert order == scoring.question_order(uids, True, "attempt-1")
|
||
|
|
|
||
|
|
|
||
|
|
def test_question_order_differs_per_seed():
|
||
|
|
uids = [f"u{index}" for index in range(12)]
|
||
|
|
orders = {tuple(scoring.question_order(uids, True, f"a{index}")) for index in range(40)}
|
||
|
|
assert len(orders) > 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_question_order_is_untouched_without_shuffle():
|
||
|
|
uids = ["a", "b", "c"]
|
||
|
|
assert scoring.question_order(uids, False, "seed") == uids
|
||
|
|
|
||
|
|
|
||
|
|
def test_option_order_is_a_stable_permutation():
|
||
|
|
uids = ["o0", "o1", "o2", "o3"]
|
||
|
|
order = scoring.option_order(uids, True, "answer-1")
|
||
|
|
assert sorted(order) == sorted(uids)
|
||
|
|
assert order == scoring.option_order(uids, True, "answer-1")
|
||
|
|
|
||
|
|
|
||
|
|
def test_token_overlap_scores_a_perfect_match():
|
||
|
|
assert scoring.token_overlap_score("write ahead logging", "write ahead logging") == 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_token_overlap_scores_an_empty_answer_zero():
|
||
|
|
assert scoring.token_overlap_score("write ahead logging", "") == 0.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_token_overlap_stays_within_bounds():
|
||
|
|
score = scoring.token_overlap_score("alpha beta gamma", "alpha delta")
|
||
|
|
assert 0.0 < score < 1.0
|
||
|
|
|
||
|
|
|
||
|
|
def test_attempt_expires_at_is_empty_without_a_limit():
|
||
|
|
assert scoring.attempt_expires_at(NOW.isoformat(), 0) == ""
|
||
|
|
|
||
|
|
|
||
|
|
def test_attempt_expires_at_adds_the_limit():
|
||
|
|
expires = scoring.attempt_expires_at(NOW.isoformat(), 900)
|
||
|
|
assert expires == (NOW + timedelta(seconds=900)).isoformat()
|
||
|
|
|
||
|
|
|
||
|
|
def test_is_expired_reads_the_stored_deadline():
|
||
|
|
expires = scoring.attempt_expires_at(NOW.isoformat(), 60)
|
||
|
|
assert scoring.is_expired(expires, NOW) is False
|
||
|
|
assert scoring.is_expired(expires, NOW + timedelta(seconds=61)) is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_is_expired_is_false_without_a_deadline():
|
||
|
|
assert scoring.is_expired("", NOW) is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_remaining_seconds_never_goes_negative():
|
||
|
|
expires = scoring.attempt_expires_at(NOW.isoformat(), 60)
|
||
|
|
assert scoring.remaining_seconds(expires, NOW) == 60
|
||
|
|
assert scoring.remaining_seconds(expires, NOW + timedelta(seconds=600)) == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_fallback_result_is_stamped_and_explains_itself():
|
||
|
|
result = scoring.fallback_result("write ahead logging", "write ahead logging", "gateway down")
|
||
|
|
assert result.graded_by == "fallback"
|
||
|
|
assert result.confidence == 0.0
|
||
|
|
assert "unavailable" in result.feedback
|