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]}",
|
Add the trust and safety subsystem and the App Store compliance work
Implements the moderation and consent obligations a social platform carries,
so the web version and any client that speaks to it enforce the same rules.
Moderation core (services/moderation/, database/moderation.py): a reportable
target registry, the content filter and its choke points, the report queue with
atomic resolution, enforcement actions, consent tracking, maturity gating, and
account deletion with a grace window.
Surfaces: POST /reports plus the member report list, /admin/moderation and the
per-report admin view, /workspaces, terms acceptance at /auth/terms, consent and
account deletion under /profile, the report button and dialog partials, the
maturity gate, and the moderation stylesheet and ReportDialog client.
Every user-generated surface stays reportable by construction: new content tables
are registered in REPORTABLE_TARGETS or listed in UNREPORTABLE_TABLES with a
reason, and the registry test fails the suite on anything left unclassified.
Docs: community guidelines, content moderation, intellectual property, privacy,
terms, contact, and the admin-only moderation operations page, plus the
moderation API group and the Devii moderation actions.
Compliance record: applecomp.md is the requirement register, applechanges.md the
gap analysis against this codebase, and appleimpl.md the implementation design
they resolve to.
Tests cover the report flow, admin moderation, consent, account deletion, terms
acceptance, workspaces, and the registry invariant across the unit, api, and e2e
tiers.
2026-08-09 00:18:20 +02:00
|
|
|
"terms_version": "1",
|
2026-07-26 14:57:18 +02:00
|
|
|
"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
|