532 lines
18 KiB
Python
Raw Normal View History

2026-07-26 14:57:18 +02:00
# retoor <retoor@molodetz.nl>
import json
from datetime import datetime, timedelta, timezone
import pytest
from devplacepy.database import get_table
from devplacepy.services.quiz import store
from devplacepy.services.quiz.store import QuizError
from devplacepy.utils import generate_uid, make_combined_slug
from tests.conftest import run_async
_counter = [0]
def _user(prefix):
_counter[0] += 1
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": f"{prefix}{_counter[0]}",
"email": f"{prefix}{_counter[0]}@t.dev",
"role": "Member",
"api_key": generate_uid(),
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return get_table("users").find_one(uid=uid)
def _quiz(owner, **overrides):
uid = generate_uid()
fields = {
"uid": uid,
"user_uid": owner["uid"],
"slug": make_combined_slug("Quiz", uid),
"title": "Quiz",
"description": "d",
"status": "draft",
"published_at": "",
"shuffle_questions": 0,
"shuffle_options": 0,
"reveal_answers": 1,
"allow_review": 1,
"time_limit_seconds": 0,
"pass_percent": 0,
"question_count": 0,
"total_points": 0,
"attempt_count": 0,
"stars": 0,
}
fields.update(overrides)
get_table("quizzes").insert(store.born_live(fields))
return store.get_quiz(uid)
def _numeric(quiz_uid, value=1.0, points=1):
return store.add_question(
quiz_uid,
{
"kind": "numeric",
"prompt": f"Value {value}",
"points": points,
"numeric_value": value,
"numeric_tolerance": 0.0,
"options": [],
},
)
def _choice(quiz_uid, points=2):
return store.add_question(
quiz_uid,
{
"kind": "single_choice",
"prompt": "Pick one",
"points": points,
"options": [
{"label": "wrong", "match_value": "", "is_correct": False},
{"label": "right", "match_value": "", "is_correct": True},
],
},
)
def test_recompute_quiz_totals_reflects_the_live_questions(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_numeric(quiz["uid"], points=3)
_choice(quiz["uid"], points=2)
refreshed = store.get_quiz(quiz["uid"])
assert refreshed["question_count"] == 2
assert refreshed["total_points"] == 5
def test_deleting_a_question_recomputes_and_renumbers(local_db):
owner = _user("qs")
quiz = _quiz(owner)
first = _numeric(quiz["uid"], points=3)
_numeric(quiz["uid"], value=2.0, points=4)
store.delete_question(quiz["uid"], first["uid"], owner["uid"])
refreshed = store.get_quiz(quiz["uid"])
assert refreshed["question_count"] == 1
assert refreshed["total_points"] == 4
assert store.list_questions(quiz["uid"])[0]["position"] == 0
def test_reorder_requires_every_question_exactly_once(local_db):
owner = _user("qs")
quiz = _quiz(owner)
first = _numeric(quiz["uid"])
_numeric(quiz["uid"], value=2.0)
with pytest.raises(QuizError):
store.reorder_questions(quiz["uid"], [first["uid"]])
def test_reorder_applies_the_new_order(local_db):
owner = _user("qs")
quiz = _quiz(owner)
first = _numeric(quiz["uid"])
second = _numeric(quiz["uid"], value=2.0)
store.reorder_questions(quiz["uid"], [second["uid"], first["uid"]])
assert [q["uid"] for q in store.list_questions(quiz["uid"])] == [second["uid"], first["uid"]]
def test_validation_errors_flags_an_empty_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
assert store.validation_errors(quiz["uid"])
def test_validation_errors_is_empty_for_a_complete_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
assert store.validation_errors(quiz["uid"]) == []
def test_validation_errors_flags_a_free_text_without_criteria(local_db):
owner = _user("qs")
quiz = _quiz(owner)
store.add_question(
quiz["uid"],
{"kind": "free_text", "prompt": "Explain", "points": 1, "options": []},
)
assert any("reference answer" in problem for problem in store.validation_errors(quiz["uid"]))
def test_validation_errors_flags_a_matching_with_one_pair(local_db):
owner = _user("qs")
quiz = _quiz(owner)
store.add_question(
quiz["uid"],
{
"kind": "matching",
"prompt": "Match",
"points": 1,
"options": [{"label": "a", "match_value": "b", "is_correct": False}],
},
)
assert any("two pairs" in problem for problem in store.validation_errors(quiz["uid"]))
def test_publish_refuses_an_incomplete_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
with pytest.raises(QuizError):
store.publish_quiz(quiz["uid"])
assert store.get_quiz(quiz["uid"])["status"] == "draft"
def test_publish_sets_the_status_once(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
published = store.publish_quiz(quiz["uid"])
assert published["status"] == "published"
assert published["published_at"]
assert published["publish_won"] is True
def test_publishing_twice_does_not_win_twice(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
again = store.publish_quiz(quiz["uid"])
assert again.get("publish_won") is not True
def test_guard_editable_refuses_a_published_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
with pytest.raises(QuizError):
store.guard_editable(quiz["uid"])
def test_every_mutating_entrypoint_is_frozen_after_publish(local_db):
owner = _user("qs")
quiz = _quiz(owner)
question = _choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
with pytest.raises(QuizError):
store.add_question(quiz["uid"], {"kind": "numeric", "prompt": "x", "options": []})
with pytest.raises(QuizError):
store.edit_question(quiz["uid"], question["uid"], {"kind": "numeric", "prompt": "x", "options": []})
with pytest.raises(QuizError):
store.delete_question(quiz["uid"], question["uid"], owner["uid"])
with pytest.raises(QuizError):
store.reorder_questions(quiz["uid"], [question["uid"]])
with pytest.raises(QuizError):
store.edit_quiz(quiz["uid"], {"title": "new"})
def test_a_published_quiz_keeps_its_question_bytes(local_db):
owner = _user("qs")
quiz = _quiz(owner)
question = _choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
with pytest.raises(QuizError):
store.edit_question(
quiz["uid"], question["uid"], {"kind": "numeric", "prompt": "changed", "options": []}
)
assert store.list_questions(quiz["uid"])[0]["prompt"] == "Pick one"
def test_can_view_quiz_hides_a_draft_from_everyone_else(local_db):
owner = _user("qs")
other = _user("qs")
quiz = _quiz(owner)
assert store.can_view_quiz(quiz, owner) is True
assert store.can_view_quiz(quiz, other) is False
assert store.can_view_quiz(quiz, None) is False
def test_can_view_quiz_allows_everyone_once_published(local_db):
owner = _user("qs")
other = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
assert store.can_view_quiz(store.get_quiz(quiz["uid"]), other) is True
assert store.can_view_quiz(store.get_quiz(quiz["uid"]), None) is True
def test_starting_twice_returns_the_same_attempt(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
first = store.start_attempt(player, quiz)
second = store.start_attempt(player, quiz)
assert first["uid"] == second["uid"]
live = get_table("quiz_attempts").count(
user_uid=player["uid"], quiz_uid=quiz["uid"], status="in_progress", deleted_at=None
)
assert live == 1
def test_starting_materializes_a_blank_answer_per_question(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
_numeric(quiz["uid"])
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
answers = store.answers_for(attempt["uid"])
assert len(answers) == 2
assert all(not answer["answered_at"] for answer in answers)
def test_starting_snapshots_the_max_points(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"], points=5)
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
assert attempt["max_points"] == 5
def test_answering_credits_the_attempt(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
question = _numeric(quiz["uid"], value=7.0, points=4)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
answer, updated, result = run_async(
store.answer(
player, quiz, attempt, question["uid"], {"answer_text": "7", "option_uids": []}
)
)
assert answer["is_correct"] == 1
assert float(answer["awarded_points"]) == 4.0
assert float(updated["score_points"]) == 4.0
assert int(updated["answered_count"]) == 1
assert result.graded_by == "auto"
def test_a_second_answer_is_refused_and_credits_nothing(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
question = _numeric(quiz["uid"], value=7.0, points=4)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
run_async(
store.answer(
player, quiz, attempt, question["uid"], {"answer_text": "7", "option_uids": []}
)
)
with pytest.raises(QuizError):
run_async(
store.answer(
player,
quiz,
store.get_attempt(attempt["uid"]),
question["uid"],
{"answer_text": "7", "option_uids": []},
)
)
refreshed = store.get_attempt(attempt["uid"])
assert float(refreshed["score_points"]) == 4.0
assert int(refreshed["answered_count"]) == 1
def test_finishing_recomputes_the_score_from_the_answers(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, pass_percent=50)
first = _numeric(quiz["uid"], value=1.0, points=2)
_numeric(quiz["uid"], value=2.0, points=2)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
run_async(
store.answer(
player, quiz, attempt, first["uid"], {"answer_text": "1", "option_uids": []}
)
)
finished, won = store.finish(quiz, store.get_attempt(attempt["uid"]))
assert won is True
assert finished["status"] == "completed"
assert float(finished["score_points"]) == 2.0
assert float(finished["score_percent"]) == 50.0
assert int(finished["passed"]) == 1
def test_finishing_twice_wins_only_once(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
store.finish(quiz, attempt)
_, won = store.finish(quiz, store.get_attempt(attempt["uid"]))
assert won is False
assert int(store.get_quiz(quiz["uid"])["attempt_count"]) == 1
def test_an_expired_attempt_refuses_further_answers(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, time_limit_seconds=60)
question = _numeric(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
get_table("quiz_attempts").update({"uid": attempt["uid"], "expires_at": past}, ["uid"])
with pytest.raises(QuizError):
run_async(
store.answer(
player,
quiz,
store.get_attempt(attempt["uid"]),
question["uid"],
{"answer_text": "1", "option_uids": []},
)
)
assert store.get_attempt(attempt["uid"])["status"] == "expired"
def test_expiry_happens_lazily_on_a_read(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, time_limit_seconds=60)
_numeric(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
get_table("quiz_attempts").update({"uid": attempt["uid"], "expires_at": past}, ["uid"])
serialized = store.serialize_attempt(quiz, store.get_attempt(attempt["uid"]), player)
assert serialized["status"] == "expired"
def test_the_serializer_withholds_the_answer_key_from_a_player(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, reveal_answers=0)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
payload = store.serialize_attempt(quiz, attempt, player)
question = payload["questions"][0]
assert "correct_boolean" not in question
assert "expected_answer" not in question
assert all("is_correct" not in option for option in question["options"])
def test_the_serializer_gives_the_owner_the_answer_key(local_db):
owner = _user("qs")
quiz = _quiz(owner, reveal_answers=0)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(owner, quiz)
payload = store.serialize_attempt(quiz, attempt, owner)
question = payload["questions"][0]
assert any("is_correct" in option for option in question["options"])
def test_the_serializer_reveals_an_answered_question_when_configured(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, reveal_answers=1)
question = _numeric(quiz["uid"], value=3.0)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
run_async(
store.answer(
player, quiz, attempt, question["uid"], {"answer_text": "3", "option_uids": []}
)
)
payload = store.serialize_attempt(quiz, store.get_attempt(attempt["uid"]), player)
assert "numeric_value" in payload["questions"][0]
def test_matching_choices_are_exposed_without_the_pairing(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, reveal_answers=0)
store.add_question(
quiz["uid"],
{
"kind": "matching",
"prompt": "Match",
"points": 1,
"options": [
{"label": "a", "match_value": "A", "is_correct": False},
{"label": "b", "match_value": "B", "is_correct": False},
],
},
)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
question = store.serialize_attempt(quiz, attempt, player)["questions"][0]
assert sorted(question["match_choices"]) == ["A", "B"]
assert all("match_value" not in option for option in question["options"])
def test_the_cascade_removes_every_child_under_one_stamp(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
store.start_attempt(player, store.get_quiz(quiz["uid"]))
stamp = datetime.now(timezone.utc).isoformat()
store.cascade_questions(quiz["uid"], owner["uid"], stamp)
for table in ("quiz_questions", "quiz_options", "quiz_attempts", "quiz_answers"):
live = get_table(table).count(quiz_uid=quiz["uid"], deleted_at=None)
stamped = get_table(table).count(quiz_uid=quiz["uid"], deleted_at=stamp)
assert live == 0
assert stamped > 0
def test_prune_attempts_keeps_completed_records(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
kept = store.start_attempt(player, quiz)
store.finish(quiz, kept)
future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
store.prune_attempts(future)
assert store.get_attempt(kept["uid"]) is not None
def test_prune_attempts_removes_an_abandoned_one(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
store.prune_attempts(future)
assert store.get_attempt(attempt["uid"]) is None
def test_the_attempt_order_is_persisted_as_json(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, shuffle_questions=1)
_numeric(quiz["uid"], value=1.0)
_numeric(quiz["uid"], value=2.0)
_numeric(quiz["uid"], value=3.0)
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
order = json.loads(attempt["question_order"])
assert sorted(order) == sorted(store.question_uids(quiz["uid"]))
assert store.attempt_order(store.get_attempt(attempt["uid"])) == order