|
# retoor <retoor@molodetz.nl>
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from devplacepy.config import QUIZ_MAX_OPTIONS, QUIZ_MAX_QUESTIONS
|
|
from devplacepy.database import get_table
|
|
from devplacepy.models import QuizDocument, QuizImportForm
|
|
from devplacepy.services.quiz import store
|
|
from devplacepy.services.quiz.store import QuizError
|
|
from devplacepy.utils import generate_uid, make_combined_slug
|
|
|
|
_counter = [0]
|
|
|
|
DOCUMENT = {
|
|
"title": "SQLite fundamentals",
|
|
"description": "Questions on WAL and indexing.",
|
|
"settings": {"shuffle_questions": True, "reveal_answers": True, "pass_percent": 70},
|
|
"questions": [
|
|
{
|
|
"kind": "single_choice",
|
|
"prompt": "Which journal mode allows concurrent readers?",
|
|
"points": 2,
|
|
"explanation": "WAL keeps readers off the writer's lock.",
|
|
"options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
|
|
},
|
|
{
|
|
"kind": "free_text",
|
|
"prompt": "Why does a partial index help?",
|
|
"points": 3,
|
|
"expected_answer": "It keeps the live query off a one bucket index.",
|
|
"grading_criteria": "Accept any answer mentioning the planner.",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
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, document):
|
|
uid = generate_uid()
|
|
get_table("quizzes").insert(
|
|
store.born_live(
|
|
{
|
|
"uid": uid,
|
|
"user_uid": owner["uid"],
|
|
"slug": make_combined_slug(document.title, uid),
|
|
"title": document.title,
|
|
"description": document.description,
|
|
"status": "draft",
|
|
"published_at": "",
|
|
"question_count": 0,
|
|
"total_points": 0,
|
|
"attempt_count": 0,
|
|
"stars": 0,
|
|
**store.document_settings(document),
|
|
}
|
|
)
|
|
)
|
|
store.import_questions(uid, document)
|
|
return store.get_quiz(uid)
|
|
|
|
|
|
def test_parse_document_accepts_the_reference_shape():
|
|
document = store.parse_document(DOCUMENT)
|
|
assert document.title == "SQLite fundamentals"
|
|
assert len(document.questions) == 2
|
|
|
|
|
|
def test_parse_document_rejects_junk():
|
|
with pytest.raises(QuizError):
|
|
store.parse_document({"title": "x"})
|
|
|
|
|
|
def test_the_import_form_parses_a_json_string():
|
|
import json
|
|
|
|
form = QuizImportForm(document=json.dumps(DOCUMENT))
|
|
assert form.document.title == "SQLite fundamentals"
|
|
|
|
|
|
def test_the_import_form_rejects_invalid_json():
|
|
with pytest.raises(ValidationError):
|
|
QuizImportForm(document="{not json")
|
|
|
|
|
|
def test_a_document_needs_at_least_one_question():
|
|
with pytest.raises(ValidationError):
|
|
QuizDocument(title="Empty quiz", questions=[])
|
|
|
|
|
|
def test_a_document_is_capped_at_the_question_limit():
|
|
question = {"kind": "numeric", "prompt": "q", "numeric_value": 1}
|
|
with pytest.raises(ValidationError):
|
|
QuizDocument(title="Too big", questions=[question] * (QUIZ_MAX_QUESTIONS + 1))
|
|
|
|
|
|
def test_a_question_is_capped_at_the_option_limit():
|
|
options = [{"label": f"L{index}"} for index in range(QUIZ_MAX_OPTIONS + 1)]
|
|
options[0]["is_correct"] = True
|
|
with pytest.raises(ValidationError):
|
|
QuizDocument(
|
|
title="Too many options",
|
|
questions=[{"kind": "single_choice", "prompt": "q", "options": options}],
|
|
)
|
|
|
|
|
|
def test_a_single_choice_needs_exactly_one_correct_option():
|
|
with pytest.raises(ValidationError):
|
|
QuizDocument(
|
|
title="Bad choice",
|
|
questions=[
|
|
{
|
|
"kind": "single_choice",
|
|
"prompt": "q",
|
|
"options": [{"label": "a"}, {"label": "b"}],
|
|
}
|
|
],
|
|
)
|
|
|
|
|
|
def test_a_free_text_needs_a_reference_or_criteria():
|
|
with pytest.raises(ValidationError):
|
|
QuizDocument(
|
|
title="Bad free text",
|
|
questions=[{"kind": "free_text", "prompt": "explain"}],
|
|
)
|
|
|
|
|
|
def test_a_matching_needs_a_right_hand_value_on_every_pair():
|
|
with pytest.raises(ValidationError):
|
|
QuizDocument(
|
|
title="Bad matching",
|
|
questions=[
|
|
{
|
|
"kind": "matching",
|
|
"prompt": "q",
|
|
"options": [{"label": "a", "match_value": "A"}, {"label": "b"}],
|
|
}
|
|
],
|
|
)
|
|
|
|
|
|
def test_document_settings_map_onto_the_quiz_columns():
|
|
settings = store.document_settings(store.parse_document(DOCUMENT))
|
|
assert settings["shuffle_questions"] == 1
|
|
assert settings["reveal_answers"] == 1
|
|
assert settings["pass_percent"] == 70
|
|
|
|
|
|
def test_importing_creates_every_question_in_order(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
questions = store.list_questions(quiz["uid"])
|
|
assert [q["kind"] for q in questions] == ["single_choice", "free_text"]
|
|
assert [q["position"] for q in questions] == [0, 1]
|
|
|
|
|
|
def test_importing_recomputes_the_totals(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
assert quiz["question_count"] == 2
|
|
assert quiz["total_points"] == 5
|
|
|
|
|
|
def test_importing_stores_the_kind_specific_fields(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
free_text = store.list_questions(quiz["uid"])[1]
|
|
assert free_text["expected_answer"].startswith("It keeps")
|
|
assert free_text["grading_criteria"].startswith("Accept")
|
|
|
|
|
|
def test_the_owner_export_round_trips_through_import(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
exported = store.export_document(quiz["uid"], True)
|
|
reimported = _quiz(owner, store.parse_document(exported))
|
|
assert reimported["question_count"] == quiz["question_count"]
|
|
assert reimported["total_points"] == quiz["total_points"]
|
|
assert store.export_document(reimported["uid"], True) == exported
|
|
|
|
|
|
def test_the_owner_export_carries_the_answer_key(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
exported = store.export_document(quiz["uid"], True)
|
|
assert any(option.get("is_correct") for option in exported["questions"][0]["options"])
|
|
assert exported["questions"][1]["expected_answer"]
|
|
|
|
|
|
def test_the_public_export_omits_the_answer_key(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
exported = store.export_document(quiz["uid"], False)
|
|
assert all("is_correct" not in option for option in exported["questions"][0]["options"])
|
|
assert "expected_answer" not in exported["questions"][1]
|
|
assert "correct_boolean" not in exported["questions"][0]
|
|
|
|
|
|
def test_the_public_export_keeps_the_prompts_and_labels(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
exported = store.export_document(quiz["uid"], False)
|
|
assert exported["questions"][0]["prompt"].startswith("Which journal mode")
|
|
assert [option["label"] for option in exported["questions"][0]["options"]] == ["DELETE", "WAL"]
|
|
|
|
|
|
def test_the_export_carries_the_settings(local_db):
|
|
owner = _user("qd")
|
|
quiz = _quiz(owner, store.parse_document(DOCUMENT))
|
|
exported = store.export_document(quiz["uid"], False)
|
|
assert exported["settings"]["pass_percent"] == 70
|
|
assert exported["settings"]["reveal_answers"] is True
|
|
|
|
|
|
def test_exporting_an_unknown_quiz_raises(local_db):
|
|
with pytest.raises(QuizError):
|
|
store.export_document("does-not-exist", True)
|