Quiiz system

This commit is contained in:
2026-07-26 16:46:41 +02:00
parent 7f17d69f5c
commit 4780016980
192 changed files with 15031 additions and 566 deletions
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+138
View File
@@ -0,0 +1,138 @@
# retoor <retoor@molodetz.nl>
import json
import time
import requests
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
_counter = [0]
CHOICE = {
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 2,
"options": "DELETE\nWAL\nMEMORY",
"correct_indexes": "1",
}
NUMERIC = {
"kind": "numeric",
"prompt": "How many bytes are in a kibibyte?",
"points": 1,
"numeric_value": "1024",
"numeric_tolerance": "0",
}
TRUE_FALSE = {
"kind": "true_false",
"prompt": "A partial index can carry a WHERE clause.",
"points": 1,
"correct_boolean": "1",
}
FREE_TEXT = {
"kind": "free_text",
"prompt": "Why does a partial index help here?",
"points": 3,
"expected_answer": "It keeps the live query off a one bucket index.",
"grading_criteria": "Accept any answer that mentions the planner.",
}
DOCUMENT = {
"title": "Imported quiz",
"description": "Created from a document.",
"settings": {"reveal_answers": True, "pass_percent": 60, "allow_review": True},
"questions": [
{
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 2,
"options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
},
{
"kind": "numeric",
"prompt": "How many bytes are in a kibibyte?",
"points": 1,
"numeric_value": 1024,
},
],
}
def signup(prefix="qz"):
_counter[0] += 1
name = f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
session = requests.Session()
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return session, name
def create_quiz(session, title="Test quiz", **settings):
payload = {"title": title, "description": "A quiz."}
payload.update(settings)
response = session.post(f"{BASE_URL}/quizzes/create", data=payload, headers=JSON)
return response.json()["data"]["slug"]
def add_question(session, slug, payload):
return session.post(f"{BASE_URL}/quizzes/{slug}/questions", data=payload, headers=JSON)
def publish(session, slug):
return session.post(f"{BASE_URL}/quizzes/{slug}/publish", headers=JSON)
def build_published(session, questions=(CHOICE, NUMERIC), title="Playable quiz", **settings):
slug = create_quiz(session, title, **settings)
for question in questions:
add_question(session, slug, question)
publish(session, slug)
return slug
def import_quiz(session, document=None):
payload = {"document": json.dumps(document or DOCUMENT)}
response = session.post(f"{BASE_URL}/quizzes/import", data=payload, headers=JSON)
return response
def start_attempt(session, slug):
response = session.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
return response.json()["data"]["uid"]
def attempt_state(session, slug, attempt_uid):
return session.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}", headers=JSON
).json()
def answer_correctly(session, slug, attempt_uid, question):
payload = {"question_uid": question["uid"]}
if question["kind"] == "single_choice":
correct = next(
(option["uid"] for option in question["options"] if option.get("is_correct")), None
)
payload["option_uids"] = correct or question["options"][1]["uid"]
elif question["kind"] == "numeric":
payload["answer_text"] = "1024"
elif question["kind"] == "true_false":
payload["answer_text"] = "true"
else:
payload["answer_text"] = "the planner picks a bad index for live rows"
return session.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer", data=payload, headers=JSON
)
+394
View File
@@ -0,0 +1,394 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timedelta, timezone
import requests
from tests.api.quizzes._helpers import (
JSON,
NUMERIC,
answer_correctly,
attempt_state,
build_published,
create_quiz,
signup,
start_attempt,
)
from tests.conftest import BASE_URL
def _play(session, slug, attempt_uid):
state = attempt_state(session, slug, attempt_uid)
for question in state["attempt"]["questions"]:
answer_correctly(session, slug, attempt_uid, question)
return session.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON
)
def test_starting_requires_auth(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Auth attempt quiz")
response = requests.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
assert response.status_code in (401, 303)
def test_a_draft_cannot_be_played(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Unplayable draft")
response = session.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
assert response.status_code == 400
def test_starting_returns_an_in_progress_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Start quiz")
player, _ = signup()
response = player.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
assert response.status_code == 200
assert response.json()["data"]["status"] == "in_progress"
def test_starting_twice_resumes_the_same_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Resume quiz")
player, _ = signup()
first = start_attempt(player, slug)
second = start_attempt(player, slug)
assert first == second
def test_the_attempt_page_lists_every_question(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Question list quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = attempt_state(player, slug, attempt_uid)
assert payload["attempt"]["question_count"] == 2
assert len(payload["attempt"]["questions"]) == 2
def test_the_attempt_snapshots_the_max_points(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Max points quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
assert attempt_state(player, slug, attempt_uid)["attempt"]["max_points"] == 3
def test_another_member_cannot_read_the_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Private attempt quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
other, _ = signup()
response = other.get(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}", headers=JSON)
assert response.status_code == 404
def test_the_quiz_author_cannot_read_a_members_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Author blind quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
response = owner.get(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}", headers=JSON)
assert response.status_code == 404
def test_answering_grades_and_credits(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Grading quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
payload = answer_correctly(player, slug, attempt_uid, question).json()
assert payload["answer"]["answered"] is True
assert payload["answer"]["is_correct"] is True
assert payload["answer"]["graded_by"] == "auto"
assert payload["attempt"]["answered_count"] == 1
def test_a_wrong_answer_scores_zero(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Wrong answer quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
payload = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": question["uid"], "answer_text": "7"},
headers=JSON,
).json()
assert payload["answer"]["is_correct"] is False
assert payload["answer"]["awarded_points"] == 0.0
def test_a_double_submit_is_refused(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Double submit quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
answer_correctly(player, slug, attempt_uid, question)
second = answer_correctly(player, slug, attempt_uid, question)
assert second.status_code == 400
assert "already answered" in second.json()["error"]["message"]
def test_a_double_submit_credits_nothing(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="No double credit quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
first = answer_correctly(player, slug, attempt_uid, question).json()
answer_correctly(player, slug, attempt_uid, question)
after = attempt_state(player, slug, attempt_uid)["attempt"]
assert after["answered_count"] == first["attempt"]["answered_count"]
assert after["score_points"] == first["attempt"]["score_points"]
def test_answering_an_unknown_question_is_a_400(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Unknown question quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
response = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": "nope", "answer_text": "x"},
headers=JSON,
)
assert response.status_code == 400
def test_an_oversized_answer_is_rejected_before_grading(app_server, seeded_db):
from devplacepy.config import QUIZ_ANSWER_MAX_CHARS
owner, _ = signup()
slug = build_published(owner, title="Oversized answer quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
response = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": question["uid"], "answer_text": "x" * (QUIZ_ANSWER_MAX_CHARS + 1)},
headers=JSON,
)
assert response.status_code == 422
def test_finishing_returns_the_result(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Finish quiz", pass_percent="50")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = _play(player, slug, attempt_uid).json()
assert payload["attempt"]["status"] == "completed"
assert payload["attempt"]["score_percent"] == 100.0
assert payload["attempt"]["passed"] is True
def test_finishing_twice_returns_the_same_result(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Finish twice quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
first = _play(player, slug, attempt_uid).json()
second = player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON
).json()
assert second["attempt"]["score_points"] == first["attempt"]["score_points"]
assert second["attempt"]["status"] == "completed"
def test_finishing_twice_increments_the_attempt_count_once(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Attempt count quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
player.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
payload = requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert payload["quiz"]["attempt_count"] == 1
def test_finishing_notifies_the_author_once(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, owner_name = signup()
slug = build_published(owner, title="Notify quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
player.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
refresh_snapshot()
author = get_table("users").find_one(username=owner_name)
count = get_table("notifications").count(user_uid=author["uid"], type="quiz_attempt")
assert count == 1
def test_playing_again_creates_a_second_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Replay quiz")
player, _ = signup()
first = start_attempt(player, slug)
_play(player, slug, first)
second = start_attempt(player, slug)
assert second != first
def test_an_expired_attempt_refuses_answers(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, _ = signup()
slug = build_published(owner, title="Expiring quiz", time_limit_seconds="3600")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
refresh_snapshot()
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
get_table("quiz_attempts").update({"uid": attempt_uid, "expires_at": past}, ["uid"])
refresh_snapshot()
response = answer_correctly(player, slug, attempt_uid, question)
assert response.status_code == 400
def test_an_expired_attempt_reads_as_expired_without_a_background_pass(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, _ = signup()
slug = build_published(owner, title="Lazy expiry quiz", time_limit_seconds="3600")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
refresh_snapshot()
past = (datetime.now(timezone.utc) - timedelta(seconds=5)).isoformat()
get_table("quiz_attempts").update({"uid": attempt_uid, "expires_at": past}, ["uid"])
refresh_snapshot()
payload = attempt_state(player, slug, attempt_uid)
assert payload["attempt"]["status"] == "expired"
def test_a_time_limited_attempt_reports_remaining_seconds(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Timer quiz", time_limit_seconds="600")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = attempt_state(player, slug, attempt_uid)
assert 0 < payload["attempt"]["remaining_seconds"] <= 600
def test_the_results_page_is_readable_by_the_attempt_owner(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Results quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
response = player.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
)
assert response.status_code == 200
assert response.json()["attempt"]["status"] == "completed"
def test_the_results_page_is_hidden_from_another_member(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Private results quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
other, _ = signup()
response = other.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
)
assert response.status_code == 404
def test_an_admin_can_read_the_results(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Admin results quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
admin = requests.Session()
admin.post(
f"{BASE_URL}/auth/login",
data={"email": seeded_db["alice"]["email"], "password": seeded_db["alice"]["password"]},
allow_redirects=True,
)
response = admin.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
)
assert response.status_code == 200
def test_the_review_is_omitted_when_the_author_disabled_it(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="No review quiz", allow_review="0")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
payload = player.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
).json()
assert payload["review"] == []
def test_the_review_is_present_when_allowed(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Review quiz", allow_review="1")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
_play(player, slug, attempt_uid)
payload = player.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
).json()
assert len(payload["review"]) == 2
def test_the_answer_key_is_withheld_mid_attempt(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Withheld key quiz", reveal_answers="0")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
payload = attempt_state(player, slug, attempt_uid)
for question in payload["attempt"]["questions"]:
assert question["correct_boolean"] is None
assert question["expected_answer"] is None
assert question["numeric_value"] is None
for option in question["options"]:
assert option["is_correct"] is None
assert option["match_value"] is None
def test_the_answer_key_reaches_the_quiz_owner(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Owner key quiz", reveal_answers="0")
attempt_uid = start_attempt(owner, slug)
payload = attempt_state(owner, slug, attempt_uid)
choice = next(
question
for question in payload["attempt"]["questions"]
if question["kind"] == "single_choice"
)
assert any(option["is_correct"] for option in choice["options"])
def test_an_answered_question_reveals_its_key_when_configured(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Reveal quiz", reveal_answers="1")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
answer_correctly(player, slug, attempt_uid, question)
after = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
assert after["numeric_value"] == 1024.0
def test_answering_a_deleted_quiz_still_uses_the_attempt_snapshot(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Deleted mid attempt quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
owner.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON)
response = player.get(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}", headers=JSON)
assert response.status_code == 404
+170
View File
@@ -0,0 +1,170 @@
# retoor <retoor@molodetz.nl>
import json
import requests
from tests.api.quizzes._helpers import (
DOCUMENT,
JSON,
build_published,
create_quiz,
import_quiz,
signup,
)
from tests.conftest import BASE_URL
def test_importing_requires_auth(app_server, seeded_db):
response = requests.post(
f"{BASE_URL}/quizzes/import", data={"document": json.dumps(DOCUMENT)}, headers=JSON
)
assert response.status_code in (401, 303)
def test_importing_creates_a_complete_draft(app_server, seeded_db):
session, _ = signup()
response = import_quiz(session)
assert response.status_code == 200
data = response.json()["data"]
assert data["question_count"] == 2
assert data["url"].endswith("/edit")
def test_an_imported_quiz_has_its_questions(app_server, seeded_db):
session, _ = signup()
slug = import_quiz(session).json()["data"]["slug"]
payload = session.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).json()
assert [question["kind"] for question in payload["questions"]] == [
"single_choice",
"numeric",
]
assert payload["quiz"]["total_points"] == 3
def test_an_imported_quiz_carries_its_settings(app_server, seeded_db):
session, _ = signup()
slug = import_quiz(session).json()["data"]["slug"]
quiz = session.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()["quiz"]
assert quiz["pass_percent"] == 60
assert quiz["reveal_answers"] is True
def test_an_imported_quiz_starts_as_a_draft(app_server, seeded_db):
session, _ = signup()
slug = import_quiz(session).json()["data"]["slug"]
quiz = session.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()["quiz"]
assert quiz["status"] == "draft"
def test_an_imported_quiz_passes_validation(app_server, seeded_db):
session, _ = signup()
slug = import_quiz(session).json()["data"]["slug"]
payload = session.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).json()
assert payload["validation_errors"] == []
def test_an_imported_quiz_can_be_published(app_server, seeded_db):
session, _ = signup()
slug = import_quiz(session).json()["data"]["slug"]
response = session.post(f"{BASE_URL}/quizzes/{slug}/publish", headers=JSON)
assert response.status_code == 200
def test_invalid_json_is_rejected(app_server, seeded_db):
session, _ = signup()
response = session.post(
f"{BASE_URL}/quizzes/import", data={"document": "{not json"}, headers=JSON
)
assert response.status_code == 422
def test_a_document_without_questions_is_rejected(app_server, seeded_db):
session, _ = signup()
response = import_quiz(session, {"title": "Empty document", "questions": []})
assert response.status_code == 422
def test_a_broken_question_is_rejected(app_server, seeded_db):
session, _ = signup()
document = {
"title": "Broken document",
"questions": [{"kind": "single_choice", "prompt": "q", "options": [{"label": "only"}]}],
}
assert import_quiz(session, document).status_code == 422
def test_an_oversized_document_is_rejected(app_server, seeded_db):
from devplacepy.config import QUIZ_MAX_QUESTIONS
session, _ = signup()
question = {"kind": "numeric", "prompt": "q", "numeric_value": 1}
document = {"title": "Huge document", "questions": [question] * (QUIZ_MAX_QUESTIONS + 1)}
assert import_quiz(session, document).status_code == 422
def test_an_unknown_kind_is_rejected(app_server, seeded_db):
session, _ = signup()
document = {"title": "Weird document", "questions": [{"kind": "telepathy", "prompt": "q"}]}
assert import_quiz(session, document).status_code == 422
def test_the_public_export_omits_the_answer_key(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Export withhold quiz")
payload = requests.get(f"{BASE_URL}/quizzes/{slug}/export", headers=JSON).json()
choice = payload["questions"][0]
assert all("is_correct" not in option for option in choice["options"])
assert "correct_boolean" not in choice
assert "numeric_value" not in payload["questions"][1]
def test_the_public_export_keeps_the_prompts(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Export prompts quiz")
payload = requests.get(f"{BASE_URL}/quizzes/{slug}/export", headers=JSON).json()
assert payload["questions"][0]["prompt"].startswith("Which journal mode")
assert [option["label"] for option in payload["questions"][0]["options"]] == [
"DELETE",
"WAL",
"MEMORY",
]
def test_the_owner_export_carries_the_answer_key(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Owner export quiz")
payload = session.get(f"{BASE_URL}/quizzes/{slug}/export", headers=JSON).json()
assert any(option.get("is_correct") for option in payload["questions"][0]["options"])
assert payload["questions"][1]["numeric_value"] == 1024.0
def test_another_member_gets_the_public_export(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Other export quiz")
other, _ = signup()
payload = other.get(f"{BASE_URL}/quizzes/{slug}/export", headers=JSON).json()
assert all("is_correct" not in option for option in payload["questions"][0]["options"])
def test_a_draft_export_is_hidden_from_a_guest(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Hidden export quiz")
assert requests.get(f"{BASE_URL}/quizzes/{slug}/export", headers=JSON).status_code == 404
def test_an_export_round_trips_back_through_import(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Round trip quiz")
exported = session.get(f"{BASE_URL}/quizzes/{slug}/export", headers=JSON).json()
reimported = import_quiz(session, exported)
assert reimported.status_code == 200
new_slug = reimported.json()["data"]["slug"]
assert session.get(f"{BASE_URL}/quizzes/{new_slug}/export", headers=JSON).json() == exported
def test_the_export_carries_the_settings(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Export settings quiz", pass_percent="75")
payload = requests.get(f"{BASE_URL}/quizzes/{slug}/export", headers=JSON).json()
assert payload["settings"]["pass_percent"] == 75
+270
View File
@@ -0,0 +1,270 @@
# retoor <retoor@molodetz.nl>
import time
from tests.api.quizzes._helpers import (
CHOICE,
FREE_TEXT,
JSON,
NUMERIC,
TRUE_FALSE,
answer_correctly,
attempt_state,
build_published,
signup,
start_attempt,
)
from tests.conftest import BASE_URL
CACHE_VERSION_TTL_SECONDS = 1.5
FILL_BLANK = {
"kind": "fill_blank",
"prompt": "Name the two pragmas",
"points": 2,
"options": "First\nSecond",
"match_values": "journal_mode\nsynchronous",
}
ORDERING = {
"kind": "ordering",
"prompt": "Order the steps",
"points": 3,
"options": "Open\nRead\nClose",
}
MATCHING = {
"kind": "matching",
"prompt": "Match the pragma",
"points": 2,
"options": "journal_mode\nsynchronous",
"match_values": "WAL\nNORMAL",
}
def _single_question(session, question, **settings):
slug = build_published(session, questions=(question,), title=f"Graded {question['kind']}", **settings)
return slug
def _drop_api_key(username):
from devplacepy.database import get_table, refresh_snapshot
from devplacepy.utils import clear_user_cache
refresh_snapshot()
user = get_table("users").find_one(username=username)
get_table("users").update({"uid": user["uid"], "api_key": ""}, ["uid"])
clear_user_cache(user["uid"])
refresh_snapshot()
time.sleep(CACHE_VERSION_TTL_SECONDS)
def _answer(session, slug, payload):
attempt_uid = start_attempt(session, slug)
question = attempt_state(session, slug, attempt_uid)["attempt"]["questions"][0]
body = {"question_uid": question["uid"], **payload(question)}
response = session.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer", data=body, headers=JSON
)
return response, question
def test_a_single_choice_is_graded_deterministically(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, CHOICE)
player, _ = signup()
response, _ = _answer(
player, slug, lambda question: {"option_uids": question["options"][1]["uid"]}
)
answer = response.json()["answer"]
assert answer["graded_by"] == "auto"
assert answer["is_correct"] is True
assert answer["awarded_points"] == 2.0
def test_a_wrong_single_choice_awards_nothing(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, CHOICE)
player, _ = signup()
response, _ = _answer(
player, slug, lambda question: {"option_uids": question["options"][0]["uid"]}
)
assert response.json()["answer"]["awarded_points"] == 0.0
def test_a_true_false_is_graded(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, TRUE_FALSE)
player, _ = signup()
response, _ = _answer(player, slug, lambda question: {"answer_text": "true"})
assert response.json()["answer"]["is_correct"] is True
def test_a_numeric_within_tolerance_is_correct(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, {**NUMERIC, "numeric_tolerance": "1"})
player, _ = signup()
response, _ = _answer(player, slug, lambda question: {"answer_text": "1023"})
assert response.json()["answer"]["is_correct"] is True
def test_a_fill_blank_gives_partial_credit(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FILL_BLANK)
player, _ = signup()
response, _ = _answer(
player, slug, lambda question: {"blanks": "journal_mode,wrong"}
)
answer = response.json()["answer"]
assert answer["awarded_points"] == 1.0
assert answer["is_correct"] is False
def test_a_full_fill_blank_is_correct(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FILL_BLANK)
player, _ = signup()
response, _ = _answer(
player, slug, lambda question: {"blanks": "journal_mode,synchronous"}
)
assert response.json()["answer"]["is_correct"] is True
def test_an_ordering_gives_prefix_credit(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, ORDERING)
player, _ = signup()
def payload(question):
uids = [option["uid"] for option in question["options"]]
return {"option_uids": ",".join([uids[0], uids[2], uids[1]])}
response, _ = _answer(player, slug, payload)
assert response.json()["answer"]["awarded_points"] == 1.0
def test_a_matching_gives_pair_credit(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, MATCHING)
player, _ = signup()
def payload(question):
uids = [option["uid"] for option in question["options"]]
return {"option_uids": ",".join(uids), "matches": "WAL,wrong"}
response, _ = _answer(player, slug, payload)
assert response.json()["answer"]["awarded_points"] == 1.0
def test_a_matching_exposes_its_right_hand_choices(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, MATCHING)
player, _ = signup()
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
assert sorted(question["match_choices"]) == ["NORMAL", "WAL"]
assert all(option["match_value"] is None for option in question["options"])
def test_a_free_text_answer_is_still_graded_without_the_gateway(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FREE_TEXT)
player, player_name = signup()
_drop_api_key(player_name)
response, _ = _answer(
player,
slug,
lambda question: {"answer_text": "the planner picks a bad index for live rows"},
)
assert response.status_code == 200
assert response.json()["answer"]["answered"] is True
def test_the_degraded_grade_is_stamped_as_a_fallback(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FREE_TEXT)
player, player_name = signup()
_drop_api_key(player_name)
response, _ = _answer(
player, slug, lambda question: {"answer_text": "a one bucket index for live rows"}
)
assert response.json()["answer"]["graded_by"] == "fallback"
def test_the_fallback_explains_itself_to_the_learner(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FREE_TEXT)
player, player_name = signup()
_drop_api_key(player_name)
response, _ = _answer(player, slug, lambda question: {"answer_text": "a one bucket index"})
assert "unavailable" in response.json()["answer"]["feedback"]
def test_the_fallback_score_stays_within_the_question_points(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FREE_TEXT)
player, player_name = signup()
_drop_api_key(player_name)
response, _ = _answer(
player,
slug,
lambda question: {"answer_text": "It keeps the live query off a one bucket index."},
)
answer = response.json()["answer"]
assert 0.0 <= answer["awarded_points"] <= 3.0
assert 0.0 <= answer["confidence"] <= 1.0
def test_an_empty_free_text_answer_scores_zero(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FREE_TEXT)
player, player_name = signup()
_drop_api_key(player_name)
response, _ = _answer(player, slug, lambda question: {"answer_text": "completely unrelated"})
assert response.json()["answer"]["awarded_points"] == 0.0
def test_the_results_page_surfaces_the_fallback_note(app_server, seeded_db):
owner, _ = signup()
slug = _single_question(owner, FREE_TEXT, allow_review="1")
player, player_name = signup()
_drop_api_key(player_name)
attempt_uid = start_attempt(player, slug)
question = attempt_state(player, slug, attempt_uid)["attempt"]["questions"][0]
player.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": question["uid"], "answer_text": "a one bucket index"},
headers=JSON,
)
player.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
payload = player.get(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results", headers=JSON
).json()
assert payload["fallback_count"] == 1
page = player.get(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/results").text
assert "quiz-grade-fallback" in page
def test_a_degraded_grade_records_a_system_audit_failure(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, _ = signup()
slug = _single_question(owner, FREE_TEXT)
player, player_name = signup()
_drop_api_key(player_name)
_answer(player, slug, lambda question: {"answer_text": "a one bucket index"})
refresh_snapshot()
count = get_table("audit_log").count(event_key="quiz.grade.failed", result="failure")
assert count >= 1
def test_only_free_text_ever_reaches_the_grader(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(CHOICE, NUMERIC, TRUE_FALSE), title="No gateway quiz")
player, _ = signup()
attempt_uid = start_attempt(player, slug)
state = attempt_state(player, slug, attempt_uid)
for question in state["attempt"]["questions"]:
payload = answer_correctly(player, slug, attempt_uid, question).json()
assert payload["answer"]["graded_by"] == "auto"
+329
View File
@@ -0,0 +1,329 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.api.quizzes._helpers import (
JSON,
build_published,
create_quiz,
signup,
)
from tests.conftest import BASE_URL
def test_the_hub_is_public(app_server, seeded_db):
response = requests.get(f"{BASE_URL}/quizzes", headers=JSON)
assert response.status_code == 200
assert "quizzes" in response.json()
def test_the_hub_renders_html_for_a_browser(app_server, seeded_db):
response = requests.get(f"{BASE_URL}/quizzes")
assert response.status_code == 200
assert "feed-layout" in response.text
assert "sidebar-card" in response.text
assert "feed-right" in response.text
def test_the_hub_exposes_the_scoreboard_and_counts(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes", headers=JSON).json()
assert "scoreboard" in payload
assert "counts" in payload
assert "pagination" in payload
def test_a_guest_cannot_create(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes", headers=JSON).json()
assert payload["viewer_can_create"] is False
def test_a_member_can_create(app_server, seeded_db):
session, _ = signup()
payload = session.get(f"{BASE_URL}/quizzes", headers=JSON).json()
assert payload["viewer_can_create"] is True
def test_creating_a_quiz_returns_the_builder_url(app_server, seeded_db):
session, _ = signup()
response = session.post(
f"{BASE_URL}/quizzes/create",
data={"title": "My first quiz", "description": "d"},
headers=JSON,
)
assert response.status_code == 200
data = response.json()["data"]
assert data["slug"]
assert data["url"].endswith("/edit")
def test_creating_a_quiz_requires_auth(app_server, seeded_db):
response = requests.post(
f"{BASE_URL}/quizzes/create", data={"title": "Nope"}, headers=JSON
)
assert response.status_code in (401, 303)
def test_a_short_title_is_rejected(app_server, seeded_db):
session, _ = signup()
response = session.post(f"{BASE_URL}/quizzes/create", data={"title": "no"}, headers=JSON)
assert response.status_code == 422
def test_a_new_quiz_starts_as_a_draft(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
payload = session.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert payload["quiz"]["status"] == "draft"
assert payload["quiz"]["viewer_can_edit"] is True
def test_a_draft_is_hidden_from_a_guest(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
assert requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).status_code == 404
def test_a_draft_is_hidden_from_another_member(app_server, seeded_db):
owner, _ = signup()
slug = create_quiz(owner)
other, _ = signup()
assert other.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).status_code == 404
def test_the_owner_sees_their_draft(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
assert session.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).status_code == 200
def test_an_admin_sees_another_members_draft(app_server, seeded_db):
owner, _ = signup()
slug = create_quiz(owner)
admin = requests.Session()
admin.post(
f"{BASE_URL}/auth/login",
data={"email": seeded_db["alice"]["email"], "password": seeded_db["alice"]["password"]},
allow_redirects=True,
)
assert admin.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).status_code == 200
def test_a_published_quiz_is_public(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Public quiz")
response = requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON)
assert response.status_code == 200
assert response.json()["quiz"]["status"] == "published"
def test_editing_a_draft_changes_its_settings(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
session.post(
f"{BASE_URL}/quizzes/edit/{slug}",
data={"title": "Renamed quiz", "description": "changed", "pass_percent": "80"},
headers=JSON,
)
payload = session.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert payload["quiz"]["title"] == "Renamed quiz"
assert payload["quiz"]["pass_percent"] == 80
def test_another_member_cannot_edit(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Not yours")
other, _ = signup()
response = other.post(
f"{BASE_URL}/quizzes/edit/{slug}", data={"title": "Hijacked"}, headers=JSON
)
assert response.status_code == 404
def test_the_owner_deletes_their_quiz(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Doomed quiz")
assert session.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON).status_code == 200
assert requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).status_code == 404
def test_an_admin_deletes_another_members_quiz(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, title="Moderated quiz")
admin = requests.Session()
admin.post(
f"{BASE_URL}/auth/login",
data={"email": seeded_db["alice"]["email"], "password": seeded_db["alice"]["password"]},
allow_redirects=True,
)
assert admin.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON).status_code == 200
def test_deleting_cascades_to_the_questions(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
session, _ = signup()
slug = build_published(session, title="Cascade quiz")
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
session.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON)
refresh_snapshot()
for table in ("quiz_questions", "quiz_options"):
assert get_table(table).count(quiz_uid=quiz["uid"], deleted_at=None) == 0
def test_the_delete_cascade_shares_one_stamp(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
session, _ = signup()
slug = build_published(session, title="Stamped quiz")
attempt_owner, _ = signup()
attempt_owner.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON)
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
session.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON)
refresh_snapshot()
stamps = set()
for table in ("quiz_questions", "quiz_options", "quiz_attempts", "quiz_answers"):
for row in get_table(table).find(quiz_uid=quiz["uid"]):
stamps.add(row["deleted_at"])
assert len(stamps) == 1
assert next(iter(stamps))
def test_the_todo_filter_excludes_a_completed_quiz(app_server, seeded_db):
from tests.api.quizzes._helpers import (
answer_correctly,
attempt_state,
start_attempt,
)
owner, _ = signup()
slug = build_published(owner, title="Todo filter quiz")
player, _ = signup()
before = player.get(f"{BASE_URL}/quizzes?filter=todo", headers=JSON).json()
assert any(item["slug"] == slug for item in before["quizzes"])
attempt_uid = start_attempt(player, slug)
state = attempt_state(player, slug, attempt_uid)
for question in state["attempt"]["questions"]:
answer_correctly(player, slug, attempt_uid, question)
player.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
after = player.get(f"{BASE_URL}/quizzes?filter=todo", headers=JSON).json()
assert not any(item["slug"] == slug for item in after["quizzes"])
done = player.get(f"{BASE_URL}/quizzes?filter=done", headers=JSON).json()
assert any(item["slug"] == slug for item in done["quizzes"])
def test_the_mine_filter_lists_only_the_viewers_quizzes(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Mine filter quiz")
payload = session.get(f"{BASE_URL}/quizzes?filter=mine", headers=JSON).json()
assert all(item["author"]["username"] for item in payload["quizzes"])
assert any(item["slug"] == slug for item in payload["quizzes"])
def test_the_drafts_filter_lists_only_drafts(app_server, seeded_db):
session, _ = signup()
draft_slug = create_quiz(session, "Draft filter quiz")
published_slug = build_published(session, title="Published filter quiz")
payload = session.get(f"{BASE_URL}/quizzes?filter=drafts", headers=JSON).json()
slugs = [item["slug"] for item in payload["quizzes"]]
assert draft_slug in slugs
assert published_slug not in slugs
def test_a_guest_gets_no_personal_filters(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes?filter=todo", headers=JSON).json()
assert payload["quizzes"] == []
def test_search_matches_the_title(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Highly searchable haddock quiz")
payload = requests.get(f"{BASE_URL}/quizzes?search=haddock", headers=JSON).json()
assert any(item["slug"] == slug for item in payload["quizzes"])
def test_search_matches_the_author_username(app_server, seeded_db):
session, name = signup("searchable")
build_published(session, title="Author search quiz")
payload = requests.get(f"{BASE_URL}/quizzes?search={name}", headers=JSON).json()
assert any(item["author"]["username"] == name for item in payload["quizzes"])
def test_an_unknown_filter_falls_back_to_all(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes?filter=nonsense", headers=JSON).json()
assert payload["filter"] == "all"
def test_the_listing_carries_the_viewer_state(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Viewer state quiz")
other, _ = signup()
payload = other.get(f"{BASE_URL}/quizzes", headers=JSON).json()
row = next(item for item in payload["quizzes"] if item["slug"] == slug)
assert row["viewer_state"] == "todo"
assert row["question_count"] == 2
assert row["total_points"] == 3
def test_a_quiz_accepts_a_comment_and_counts_it(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
session, _ = signup()
slug = build_published(session, title="Commented quiz")
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
session.post(
f"{BASE_URL}/comments/create",
data={"content": "Nice quiz", "target_uid": quiz["uid"], "target_type": "quiz"},
headers=JSON,
)
payload = requests.get(f"{BASE_URL}/quizzes", headers=JSON).json()
row = next(item for item in payload["quizzes"] if item["slug"] == slug)
assert row["comment_count"] == 1
def test_a_quiz_accepts_a_star_vote(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
session, _ = signup()
slug = build_published(session, title="Voted quiz")
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
voter, _ = signup()
voter.post(f"{BASE_URL}/votes/quiz/{quiz['uid']}", data={"value": "1"}, headers=JSON)
payload = requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert payload["star_count"] == 1
def test_the_bare_uid_resolves_like_the_slug(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
session, _ = signup()
slug = build_published(session, title="Uid lookup quiz")
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
response = requests.get(f"{BASE_URL}/quizzes/{quiz['uid']}", headers=JSON)
assert response.status_code == 200
def test_an_unknown_quiz_is_a_404(app_server, seeded_db):
assert requests.get(f"{BASE_URL}/quizzes/nope-nope", headers=JSON).status_code == 404
def test_the_new_quiz_page_requires_auth(app_server, seeded_db):
response = requests.get(f"{BASE_URL}/quizzes/new", headers=JSON, allow_redirects=False)
assert response.status_code in (401, 303)
def test_the_new_quiz_page_renders_for_a_member(app_server, seeded_db):
session, _ = signup()
response = session.get(f"{BASE_URL}/quizzes/new")
assert response.status_code == 200
assert "quiz-form" in response.text
def test_the_hub_nav_entry_is_visible_to_a_guest(app_server, seeded_db):
response = requests.get(f"{BASE_URL}/quizzes")
assert 'href="/quizzes"' in response.text
assert "Quizzes" in response.text
+150
View File
@@ -0,0 +1,150 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.api.quizzes._helpers import (
JSON,
NUMERIC,
answer_correctly,
attempt_state,
build_published,
create_quiz,
signup,
start_attempt,
)
from tests.conftest import BASE_URL
def _play(session, slug, correct=True):
attempt_uid = start_attempt(session, slug)
state = attempt_state(session, slug, attempt_uid)
for question in state["attempt"]["questions"]:
if correct:
answer_correctly(session, slug, attempt_uid, question)
else:
session.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": question["uid"], "answer_text": "-1"},
headers=JSON,
)
session.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
def _entries(slug, limit=None):
url = f"{BASE_URL}/quizzes/{slug}/leaderboard"
if limit is not None:
url = f"{url}?limit={limit}"
return requests.get(url, headers=JSON).json()["entries"]
def test_the_leaderboard_is_public(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Public board quiz")
response = requests.get(f"{BASE_URL}/quizzes/{slug}/leaderboard", headers=JSON)
assert response.status_code == 200
assert response.json()["entries"] == []
def test_the_leaderboard_reports_its_quiz(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board uid quiz")
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
payload = requests.get(f"{BASE_URL}/quizzes/{slug}/leaderboard", headers=JSON).json()
assert payload["quiz_uid"] == quiz["uid"]
def test_a_completed_attempt_appears(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board entry quiz")
player, player_name = signup()
_play(player, slug)
entries = _entries(slug)
assert len(entries) == 1
assert entries[0]["user"]["username"] == player_name
assert entries[0]["score_percent"] == 100.0
assert entries[0]["rank"] == 1
def test_an_unfinished_attempt_never_appears(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board unfinished quiz")
player, _ = signup()
start_attempt(player, slug)
assert _entries(slug) == []
def test_the_best_score_ranks_first(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board ranking quiz")
weak, weak_name = signup()
strong, strong_name = signup()
_play(weak, slug, correct=False)
_play(strong, slug, correct=True)
entries = _entries(slug)
assert entries[0]["user"]["username"] == strong_name
assert entries[-1]["user"]["username"] == weak_name
def test_ranks_are_gapless(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board gapless quiz")
for _ in range(3):
player, _ = signup()
_play(player, slug)
ranks = [entry["rank"] for entry in _entries(slug)]
assert ranks == list(range(1, len(ranks) + 1))
def test_the_limit_bounds_the_board(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board limit quiz")
for _ in range(3):
player, _ = signup()
_play(player, slug)
assert len(_entries(slug, limit=2)) == 2
def test_the_pass_verdict_is_carried(app_server, seeded_db):
owner, _ = signup()
slug = build_published(
owner, questions=(NUMERIC,), title="Board verdict quiz", pass_percent="50"
)
player, _ = signup()
_play(player, slug)
assert _entries(slug)[0]["passed"] is True
def test_the_board_projects_users_through_the_safe_schema(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board safe user quiz")
player, _ = signup()
_play(player, slug)
user = _entries(slug)[0]["user"]
assert "email" not in user
assert "api_key" not in user
def test_the_authors_own_attempt_is_on_the_quiz_board(app_server, seeded_db):
owner, owner_name = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board author quiz")
_play(owner, slug)
assert _entries(slug)[0]["user"]["username"] == owner_name
def test_a_draft_board_is_hidden_from_a_guest(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Board draft quiz")
response = requests.get(f"{BASE_URL}/quizzes/{slug}/leaderboard", headers=JSON)
assert response.status_code == 404
def test_the_detail_page_embeds_the_board(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board embedded quiz")
player, player_name = signup()
_play(player, slug)
payload = requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert any(entry["user"]["username"] == player_name for entry in payload["leaderboard"])
+183
View File
@@ -0,0 +1,183 @@
# retoor <retoor@molodetz.nl>
from tests.api.quizzes._helpers import (
CHOICE,
JSON,
NUMERIC,
add_question,
build_published,
create_quiz,
publish,
signup,
)
from tests.conftest import BASE_URL
def _frozen_quiz():
session, _ = signup()
slug = create_quiz(session, "Frozen quiz")
add_question(session, slug, CHOICE)
question_uid = session.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).json()[
"questions"
][0]["uid"]
publish(session, slug)
return session, slug, question_uid
def test_publishing_an_empty_quiz_is_refused(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Empty quiz")
response = publish(session, slug)
assert response.status_code == 400
assert "question" in response.json()["error"]["message"]
def test_a_refused_publish_leaves_the_quiz_a_draft(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Still a draft")
publish(session, slug)
payload = session.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert payload["quiz"]["status"] == "draft"
def test_the_builder_lists_the_validation_problems(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Checklist quiz")
payload = session.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).json()
assert payload["validation_errors"]
def test_a_free_text_without_criteria_blocks_publishing(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
session, _ = signup()
slug = create_quiz(session, "Incomplete free text quiz")
add_question(session, slug, NUMERIC)
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
question = get_table("quiz_questions").find_one(quiz_uid=quiz["uid"], deleted_at=None)
get_table("quiz_questions").update(
{"uid": question["uid"], "kind": "free_text", "expected_answer": "", "grading_criteria": ""},
["uid"],
)
refresh_snapshot()
response = publish(session, slug)
assert response.status_code == 400
def test_a_single_choice_without_a_correct_option_blocks_publishing(app_server, seeded_db):
from devplacepy.database import get_table, refresh_snapshot
session, _ = signup()
slug = create_quiz(session, "Broken choice quiz")
add_question(session, slug, CHOICE)
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
get_table("quiz_options").update_many(
[
{"uid": row["uid"], "is_correct": 0}
for row in get_table("quiz_options").find(quiz_uid=quiz["uid"], deleted_at=None)
],
["uid"],
)
refresh_snapshot()
assert publish(session, slug).status_code == 400
def test_publishing_a_complete_quiz_succeeds(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session, "Complete quiz")
add_question(session, slug, CHOICE)
response = publish(session, slug)
assert response.status_code == 200
assert response.json()["data"]["status"] == "published"
def test_publishing_twice_is_harmless(app_server, seeded_db):
session, _ = signup()
slug = build_published(session, title="Twice published quiz")
assert publish(session, slug).status_code == 200
def test_only_the_owner_can_publish(app_server, seeded_db):
owner, _ = signup()
slug = create_quiz(owner, "Owner only quiz")
add_question(owner, slug, CHOICE)
other, _ = signup()
assert publish(other, slug).status_code == 404
def test_a_published_quiz_refuses_a_settings_edit(app_server, seeded_db):
session, slug, _ = _frozen_quiz()
response = session.post(
f"{BASE_URL}/quizzes/edit/{slug}", data={"title": "Renamed"}, headers=JSON
)
assert response.status_code == 400
def test_a_published_quiz_refuses_a_new_question(app_server, seeded_db):
session, slug, _ = _frozen_quiz()
assert add_question(session, slug, NUMERIC).status_code == 400
def test_a_published_quiz_refuses_a_question_edit(app_server, seeded_db):
session, slug, question_uid = _frozen_quiz()
response = session.post(
f"{BASE_URL}/quizzes/{slug}/questions/{question_uid}", data=CHOICE, headers=JSON
)
assert response.status_code == 400
def test_a_published_quiz_refuses_a_question_delete(app_server, seeded_db):
session, slug, question_uid = _frozen_quiz()
response = session.post(
f"{BASE_URL}/quizzes/{slug}/questions/{question_uid}/delete", headers=JSON
)
assert response.status_code == 400
def test_a_published_quiz_refuses_a_reorder(app_server, seeded_db):
session, slug, question_uid = _frozen_quiz()
response = session.post(
f"{BASE_URL}/quizzes/{slug}/questions/reorder",
data={"order": question_uid},
headers=JSON,
)
assert response.status_code == 400
def test_a_refused_write_leaves_the_question_untouched(app_server, seeded_db):
session, slug, question_uid = _frozen_quiz()
before = session.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).json()["questions"]
session.post(
f"{BASE_URL}/quizzes/{slug}/questions/{question_uid}",
data={**CHOICE, "prompt": "Rewritten"},
headers=JSON,
)
after = session.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).json()["questions"]
assert before == after
def test_there_is_no_unpublish_route(app_server, seeded_db):
session, slug, _ = _frozen_quiz()
assert session.post(f"{BASE_URL}/quizzes/{slug}/unpublish", headers=JSON).status_code == 404
def test_delete_still_works_on_a_published_quiz(app_server, seeded_db):
session, slug, _ = _frozen_quiz()
assert session.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON).status_code == 200
def test_the_builder_renders_read_only_after_publishing(app_server, seeded_db):
session, slug, _ = _frozen_quiz()
page = session.get(f"{BASE_URL}/quizzes/{slug}/edit").text
assert "permanently frozen" in page
assert "Add a question" not in page
def test_the_publish_flag_disappears_from_the_quiz(app_server, seeded_db):
session, slug, _ = _frozen_quiz()
payload = session.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON).json()
assert payload["quiz"]["viewer_can_edit"] is False
assert payload["quiz"]["published_at"]
+293
View File
@@ -0,0 +1,293 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.api.quizzes._helpers import (
CHOICE,
FREE_TEXT,
JSON,
NUMERIC,
TRUE_FALSE,
add_question,
create_quiz,
signup,
)
from tests.conftest import BASE_URL
FILL_BLANK = {
"kind": "fill_blank",
"prompt": "Complete the pragma names",
"points": 2,
"options": "First blank\nSecond blank",
"match_values": "journal_mode\nsynchronous",
}
ORDERING = {
"kind": "ordering",
"prompt": "Order the steps",
"points": 2,
"options": "Open\nRead\nClose",
}
MATCHING = {
"kind": "matching",
"prompt": "Match the pragma to its value",
"points": 2,
"options": "journal_mode\nsynchronous",
"match_values": "WAL\nNORMAL",
}
def _builder(session, slug):
return session.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).json()
def test_the_builder_requires_auth(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = requests.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON, allow_redirects=False)
assert response.status_code in (401, 303, 404)
def test_the_builder_lists_the_question_kinds(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
payload = _builder(session, slug)
assert len(payload["kinds"]) == 8
assert {kind["key"] for kind in payload["kinds"]} >= {"single_choice", "free_text"}
def test_another_member_cannot_open_the_builder(app_server, seeded_db):
owner, _ = signup()
slug = create_quiz(owner)
other, _ = signup()
assert other.get(f"{BASE_URL}/quizzes/{slug}/edit", headers=JSON).status_code == 404
def test_adding_a_single_choice_stores_its_options(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
assert add_question(session, slug, CHOICE).status_code == 200
question = _builder(session, slug)["questions"][0]
assert question["kind"] == "single_choice"
assert [option["label"] for option in question["options"]] == ["DELETE", "WAL", "MEMORY"]
assert [option["is_correct"] for option in question["options"]] == [False, True, False]
def test_adding_a_true_false_stores_the_boolean(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, TRUE_FALSE)
assert _builder(session, slug)["questions"][0]["correct_boolean"] is True
def test_adding_a_numeric_stores_the_value_and_tolerance(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, {**NUMERIC, "numeric_tolerance": "0.5"})
question = _builder(session, slug)["questions"][0]
assert question["numeric_value"] == 1024.0
assert question["numeric_tolerance"] == 0.5
def test_adding_a_free_text_stores_the_criteria(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, FREE_TEXT)
question = _builder(session, slug)["questions"][0]
assert question["expected_answer"].startswith("It keeps")
assert question["grading_criteria"].startswith("Accept")
def test_adding_a_fill_blank_stores_the_accepted_answers(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, FILL_BLANK)
question = _builder(session, slug)["questions"][0]
assert [option["match_value"] for option in question["options"]] == [
"journal_mode",
"synchronous",
]
def test_adding_an_ordering_stores_the_author_order(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, ORDERING)
question = _builder(session, slug)["questions"][0]
assert [option["label"] for option in question["options"]] == ["Open", "Read", "Close"]
def test_adding_a_matching_stores_the_pairs(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, MATCHING)
question = _builder(session, slug)["questions"][0]
assert [option["match_value"] for option in question["options"]] == ["WAL", "NORMAL"]
def test_a_choice_needs_at_least_two_options(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = add_question(
session, slug, {**CHOICE, "options": "OnlyOne", "correct_indexes": "0"}
)
assert response.status_code == 422
def test_a_single_choice_needs_exactly_one_correct_option(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = add_question(session, slug, {**CHOICE, "correct_indexes": "0,1"})
assert response.status_code == 422
def test_a_free_text_needs_a_reference_or_criteria(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = add_question(
session, slug, {"kind": "free_text", "prompt": "Explain", "points": 1}
)
assert response.status_code == 422
def test_an_unknown_kind_is_rejected(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = add_question(session, slug, {"kind": "telepathy", "prompt": "Guess"})
assert response.status_code == 422
def test_points_are_bounded(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
assert add_question(session, slug, {**NUMERIC, "points": "0"}).status_code == 422
assert add_question(session, slug, {**NUMERIC, "points": "500"}).status_code == 422
def test_questions_are_appended_in_order(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
add_question(session, slug, NUMERIC)
add_question(session, slug, TRUE_FALSE)
positions = [question["position"] for question in _builder(session, slug)["questions"]]
assert positions == [0, 1, 2]
def test_adding_a_question_recomputes_the_totals(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
add_question(session, slug, NUMERIC)
quiz = _builder(session, slug)["quiz"]
assert quiz["question_count"] == 2
assert quiz["total_points"] == 3
def test_editing_a_question_replaces_its_options(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
question_uid = _builder(session, slug)["questions"][0]["uid"]
session.post(
f"{BASE_URL}/quizzes/{slug}/questions/{question_uid}",
data={**CHOICE, "prompt": "Rewritten", "options": "A\nB", "correct_indexes": "0"},
headers=JSON,
)
question = _builder(session, slug)["questions"][0]
assert question["prompt"] == "Rewritten"
assert [option["label"] for option in question["options"]] == ["A", "B"]
def test_editing_a_question_recomputes_the_totals(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
question_uid = _builder(session, slug)["questions"][0]["uid"]
session.post(
f"{BASE_URL}/quizzes/{slug}/questions/{question_uid}",
data={**CHOICE, "points": "9"},
headers=JSON,
)
assert _builder(session, slug)["quiz"]["total_points"] == 9
def test_editing_an_unknown_question_is_a_400(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = session.post(
f"{BASE_URL}/quizzes/{slug}/questions/nope", data=CHOICE, headers=JSON
)
assert response.status_code == 400
def test_deleting_a_question_renumbers_the_rest(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
add_question(session, slug, NUMERIC)
first_uid = _builder(session, slug)["questions"][0]["uid"]
session.post(f"{BASE_URL}/quizzes/{slug}/questions/{first_uid}/delete", headers=JSON)
questions = _builder(session, slug)["questions"]
assert len(questions) == 1
assert questions[0]["position"] == 0
def test_deleting_an_unknown_question_is_a_400(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = session.post(f"{BASE_URL}/quizzes/{slug}/questions/nope/delete", headers=JSON)
assert response.status_code == 400
def test_reordering_applies_the_new_order(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
add_question(session, slug, NUMERIC)
uids = [question["uid"] for question in _builder(session, slug)["questions"]]
session.post(
f"{BASE_URL}/quizzes/{slug}/questions/reorder",
data={"order": ",".join(reversed(uids))},
headers=JSON,
)
assert [q["uid"] for q in _builder(session, slug)["questions"]] == list(reversed(uids))
def test_reordering_needs_every_question(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
add_question(session, slug, NUMERIC)
uids = [question["uid"] for question in _builder(session, slug)["questions"]]
response = session.post(
f"{BASE_URL}/quizzes/{slug}/questions/reorder", data={"order": uids[0]}, headers=JSON
)
assert response.status_code == 400
def test_reordering_needs_a_non_empty_order(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
add_question(session, slug, CHOICE)
response = session.post(
f"{BASE_URL}/quizzes/{slug}/questions/reorder", data={"order": ""}, headers=JSON
)
assert response.status_code == 422
def test_another_member_cannot_add_a_question(app_server, seeded_db):
owner, _ = signup()
slug = create_quiz(owner)
other, _ = signup()
assert add_question(other, slug, CHOICE).status_code == 404
def test_a_guest_cannot_add_a_question(app_server, seeded_db):
session, _ = signup()
slug = create_quiz(session)
response = requests.post(
f"{BASE_URL}/quizzes/{slug}/questions", data=CHOICE, headers=JSON
)
assert response.status_code in (401, 303)
+232
View File
@@ -0,0 +1,232 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.api.quizzes._helpers import (
JSON,
NUMERIC,
answer_correctly,
attempt_state,
build_published,
signup,
start_attempt,
)
from tests.conftest import BASE_URL
def _play(session, slug, correct=True):
attempt_uid = start_attempt(session, slug)
state = attempt_state(session, slug, attempt_uid)
for question in state["attempt"]["questions"]:
if correct:
answer_correctly(session, slug, attempt_uid, question)
else:
session.post(
f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/answer",
data={"question_uid": question["uid"], "answer_text": "-1"},
headers=JSON,
)
session.post(f"{BASE_URL}/quizzes/{slug}/attempts/{attempt_uid}/finish", headers=JSON)
return attempt_uid
def _entry(username, limit=100):
payload = requests.get(f"{BASE_URL}/quizzes/scoreboard?limit={limit}", headers=JSON).json()
for entry in payload["scoreboard"]:
if entry["user"]["username"] == username:
return entry
return None
def test_the_scoreboard_is_public(app_server, seeded_db):
response = requests.get(f"{BASE_URL}/quizzes/scoreboard", headers=JSON)
assert response.status_code == 200
assert "scoreboard" in response.json()
def test_the_scoreboard_reports_its_limit(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes/scoreboard?limit=5", headers=JSON).json()
assert payload["limit"] == 5
assert len(payload["scoreboard"]) <= 5
def test_the_limit_is_bounded(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes/scoreboard?limit=9999", headers=JSON).json()
assert payload["limit"] == 100
def test_completing_a_quiz_puts_a_member_on_the_board(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Board entry quiz")
player, player_name = signup()
_play(player, slug)
entry = _entry(player_name)
assert entry is not None
assert entry["total_points"] == 1.0
assert entry["quizzes_completed"] == 1
def test_the_board_projects_users_through_the_safe_schema(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Safe user quiz")
player, player_name = signup()
_play(player, slug)
entry = _entry(player_name)
assert "email" not in entry["user"]
assert "api_key" not in entry["user"]
assert "password_hash" not in entry["user"]
def test_the_board_cannot_be_farmed_by_replaying(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Farming quiz")
player, player_name = signup()
_play(player, slug)
before = _entry(player_name)["total_points"]
for _ in range(4):
_play(player, slug)
assert _entry(player_name)["total_points"] == before
def test_a_worse_replay_never_lowers_the_score(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Worse replay quiz")
player, player_name = signup()
_play(player, slug, correct=True)
_play(player, slug, correct=False)
assert _entry(player_name)["total_points"] == 1.0
def test_a_better_replay_raises_the_score(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Better replay quiz")
player, player_name = signup()
_play(player, slug, correct=False)
assert _entry(player_name)["total_points"] == 0.0
_play(player, slug, correct=True)
assert _entry(player_name)["total_points"] == 1.0
def test_an_author_scores_on_their_own_quiz(app_server, seeded_db):
owner, owner_name = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Own quiz scoring")
_play(owner, slug)
entry = _entry(owner_name)
assert entry is not None
assert entry["total_points"] == 1.0
assert entry["quizzes_completed"] == 1
def test_two_quizzes_add_up(app_server, seeded_db):
owner, _ = signup()
first = build_published(owner, questions=(NUMERIC,), title="Sum quiz one")
second = build_published(owner, questions=(NUMERIC,), title="Sum quiz two")
player, player_name = signup()
_play(player, first)
_play(player, second)
entry = _entry(player_name)
assert entry["total_points"] == 2.0
assert entry["quizzes_completed"] == 2
def test_an_unfinished_attempt_scores_nothing(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Unfinished quiz")
player, player_name = signup()
start_attempt(player, slug)
assert _entry(player_name) is None
def test_the_viewer_standing_is_returned_for_a_member(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Standing quiz")
player, player_name = signup()
_play(player, slug)
payload = player.get(f"{BASE_URL}/quizzes/scoreboard", headers=JSON).json()
assert payload["viewer_standing"]["user"]["username"] == player_name
def test_the_viewer_standing_is_absent_for_a_guest(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes/scoreboard", headers=JSON).json()
assert payload["viewer_standing"] is None
def test_the_hub_carries_the_same_board(app_server, seeded_db):
from devplacepy.config import QUIZ_SCOREBOARD_LIMIT
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Hub board quiz")
player, _ = signup()
_play(player, slug)
hub = requests.get(f"{BASE_URL}/quizzes", headers=JSON).json()["scoreboard"]
board = requests.get(
f"{BASE_URL}/quizzes/scoreboard?limit={QUIZ_SCOREBOARD_LIMIT}", headers=JSON
).json()["scoreboard"]
assert len(hub) <= QUIZ_SCOREBOARD_LIMIT
assert [entry["user"]["username"] for entry in hub] == [
entry["user"]["username"] for entry in board
]
def test_the_hub_reports_the_viewer_progress(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Progress quiz")
player, _ = signup()
_play(player, slug)
payload = player.get(f"{BASE_URL}/quizzes", headers=JSON).json()
assert payload["viewer_progress"]["completed"] >= 1
def test_ranks_are_gapless(app_server, seeded_db):
payload = requests.get(f"{BASE_URL}/quizzes/scoreboard?limit=100", headers=JSON).json()
ranks = [entry["rank"] for entry in payload["scoreboard"]]
assert ranks == list(range(1, len(ranks) + 1))
def test_a_perfect_run_is_counted(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Perfect quiz")
player, player_name = signup()
_play(player, slug)
assert _entry(player_name)["perfect_count"] == 1
def test_completing_your_own_quiz_updates_the_progress_card(app_server, seeded_db):
author, author_name = signup()
slug = build_published(author, questions=(NUMERIC,), title="Own quiz progress")
_play(author, slug)
payload = author.get(f"{BASE_URL}/quizzes", headers=JSON).json()
progress = payload["viewer_progress"]
entry = _entry(author_name)
assert entry is not None
assert progress["completed"] == entry["quizzes_completed"] == 1
assert progress["avg_percent"] == entry["avg_percent"] == 100.0
assert progress["total_points"] == entry["total_points"] == 1.0
assert progress["perfect_count"] == entry["perfect_count"] == 1
assert payload["viewer_standing"]["user"]["username"] == author_name
def test_the_author_reaches_the_board_after_their_own_run(app_server, seeded_db):
author, author_name = signup()
slug = build_published(author, questions=(NUMERIC,), title="Own quiz on the board")
assert _entry(author_name) is None
_play(author, slug)
assert _entry(author_name) is not None
standing = author.get(f"{BASE_URL}/quizzes/scoreboard", headers=JSON).json()[
"viewer_standing"
]
assert standing["user"]["username"] == author_name
assert standing["quizzes_completed"] == 1
def test_the_progress_card_matches_the_board_for_a_ranked_player(app_server, seeded_db):
owner, _ = signup()
slug = build_published(owner, questions=(NUMERIC,), title="Ranked progress quiz")
player, player_name = signup()
_play(player, slug)
payload = player.get(f"{BASE_URL}/quizzes", headers=JSON).json()
progress = payload["viewer_progress"]
entry = _entry(player_name)
assert progress["completed"] == entry["quizzes_completed"]
assert progress["avg_percent"] == entry["avg_percent"]
assert progress["total_points"] == entry["total_points"]