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
+157
View File
@@ -0,0 +1,157 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
JSON_dt = {"Accept": "application/json"}
_counter_dt = [0]
def _unique_dt(prefix="dt"):
_counter_dt[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_dt[0]}"
def _member_dt():
name = _unique_dt("dtmem")
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 _admin_dt(seeded_db):
session = requests.Session()
creds = seeded_db["alice"]
session.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return session
def _seed_task(owner_uid, enabled=True):
from devplacepy.database import db
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
from devplacepy.utils import generate_uid
uid = generate_uid()
db["devii_tasks"].insert(
{
"uid": uid,
"owner_kind": "user",
"owner_id": owner_uid,
"label": _unique_dt("task"),
"prompt": "work",
"enabled": enabled,
"status": "pending" if enabled else "disabled",
"kind": "interval",
"every_seconds": 900,
"max_runs": 10,
"run_count": 3,
"failure_count": 0,
"created_at": to_iso(now_utc()),
"next_run_at": to_iso(now_utc()),
"expires_at": None,
"deleted_at": None,
"deleted_by": None,
}
)
return uid
def _admin_uid(seeded_db):
from devplacepy.database import get_table
return get_table("users").find_one(username=seeded_db["alice"]["username"])["uid"]
def test_devii_tasks_requires_admin(seeded_db):
member, _ = _member_dt()
response = member.get(f"{BASE_URL}/admin/devii-tasks", allow_redirects=False)
assert response.status_code in (302, 303)
def test_devii_tasks_lists_a_task_with_its_owner(seeded_db):
uid = _seed_task(_admin_uid(seeded_db))
admin = _admin_dt(seeded_db)
body = admin.get(f"{BASE_URL}/admin/devii-tasks", headers=JSON_dt).json()
assert body["admin_section"] == "devii-tasks"
entry = next(item for item in body["items"] if item["uid"] == uid)
assert entry["owner"] == seeded_db["alice"]["username"]
assert entry["owner_is_admin"] is True
assert entry["schedule"] == "every 900s"
assert entry["run_count"] == 3
def test_devii_tasks_reports_the_configured_bounds(seeded_db):
admin = _admin_dt(seeded_db)
body = admin.get(f"{BASE_URL}/admin/devii-tasks", headers=JSON_dt).json()
for key in ("max_concurrent", "max_per_owner", "max_failures", "idle_days"):
assert key in body["limits"]
def test_devii_tasks_inactive_filter_excludes_enabled(seeded_db):
enabled = _seed_task(_admin_uid(seeded_db))
disabled = _seed_task(_admin_uid(seeded_db), enabled=False)
admin = _admin_dt(seeded_db)
body = admin.get(
f"{BASE_URL}/admin/devii-tasks", headers=JSON_dt, params={"state": "inactive"}
).json()
uids = {item["uid"] for item in body["items"]}
assert disabled in uids
assert enabled not in uids
def test_admin_can_disable_a_task(seeded_db):
from devplacepy.database import db
uid = _seed_task(_admin_uid(seeded_db))
admin = _admin_dt(seeded_db)
response = admin.post(
f"{BASE_URL}/admin/devii-tasks/{uid}/disable",
headers=JSON_dt,
allow_redirects=False,
)
assert response.status_code in (200, 302, 303)
row = db["devii_tasks"].find_one(uid=uid)
assert not row["enabled"]
assert row["status"] == "disabled"
def test_admin_can_delete_a_task(seeded_db):
from devplacepy.database import db
uid = _seed_task(_admin_uid(seeded_db))
admin = _admin_dt(seeded_db)
response = admin.post(
f"{BASE_URL}/admin/devii-tasks/{uid}/delete",
headers=JSON_dt,
allow_redirects=False,
)
assert response.status_code in (200, 302, 303)
assert db["devii_tasks"].find_one(uid=uid, deleted_at=None) is None
def test_member_cannot_disable_a_task(seeded_db):
from devplacepy.database import db
uid = _seed_task(_admin_uid(seeded_db))
member, _ = _member_dt()
response = member.post(
f"{BASE_URL}/admin/devii-tasks/{uid}/disable", allow_redirects=False
)
assert response.status_code in (302, 303)
assert db["devii_tasks"].find_one(uid=uid)["enabled"]
+56
View File
@@ -145,3 +145,59 @@ def test_restore_revoked_award_recomputes_stats(seeded_db):
assert row.get("award_count") == 1
assert row.get("last_award_uid") == uid
assert requests.get(f"{BASE_URL}/awards/{slug}/256").status_code in (302, 404)
def _create_quiz_trash(session):
slug = session.post(
f"{BASE_URL}/quizzes/create",
headers=JSON_trash,
data={"title": _unique_trash("trrquiz"), "description": "restore quiz body"},
).json()["data"]["slug"]
session.post(
f"{BASE_URL}/quizzes/{slug}/questions",
headers=JSON_trash,
data={
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 2,
"options": "DELETE\nWAL",
"correct_indexes": "1",
},
)
session.post(f"{BASE_URL}/quizzes/{slug}/publish", headers=JSON_trash)
return slug
def test_restore_revives_a_quiz_with_its_whole_cascade(seeded_db):
from devplacepy.database import get_table, refresh_snapshot
author, _ = _member_trash()
player, _ = _member_trash()
slug = _create_quiz_trash(author)
player.post(f"{BASE_URL}/quizzes/{slug}/attempts", headers=JSON_trash)
refresh_snapshot()
quiz = get_table("quizzes").find_one(slug=slug)
author.post(f"{BASE_URL}/quizzes/delete/{slug}", headers=JSON_trash, allow_redirects=False)
refresh_snapshot()
stamps = set()
for table in ("quiz_questions", "quiz_options", "quiz_attempts", "quiz_answers"):
rows = list(get_table(table).find(quiz_uid=quiz["uid"]))
assert rows
for row in rows:
assert row["deleted_at"]
stamps.add(row["deleted_at"])
assert len(stamps) == 1
admin = _admin_trash(seeded_db)
response = admin.post(
f"{BASE_URL}/admin/trash/quizzes/{quiz['uid']}/restore",
headers=JSON_trash,
allow_redirects=False,
)
assert response.status_code == 200, response.text[:300]
refresh_snapshot()
for table in ("quiz_questions", "quiz_options", "quiz_attempts", "quiz_answers"):
assert get_table(table).count(quiz_uid=quiz["uid"], deleted_at=None) > 0
assert requests.get(f"{BASE_URL}/quizzes/{slug}", headers=JSON_trash).status_code == 200
+3 -1
View File
@@ -174,7 +174,9 @@ def test_steal_ready_unprotected_crop_transfers_coins(app_server, seeded_db):
thief_user = get_table("users").find_one(username=thief)
thief_farm = store.get_farm(thief_user["uid"])
assert int(thief_farm.get("coins", 0)) > 0
assert (get_table("game_plots").find_one(uid=plot["uid"]).get("crop_key") or "") == ""
raided = get_table("game_plots").find_one(uid=plot["uid"])
assert (raided.get("crop_key") or "") == "shell"
assert float(raided.get("raided_fraction") or 0) > 0
def _ripen_owner_plot(owner, slot=0):
+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"]
+62 -2
View File
@@ -124,6 +124,10 @@ _counter_reactions = [0]
AJAX_reactions = {"X-Requested-With": "fetch"}
ROCKET = REACTION_EMOJI[2]
HEART = REACTION_EMOJI[1]
UNICORN = "\U0001F984"
SKIN_TONE = "\U0001F44D\U0001F3FF"
ZWJ_SEQUENCE = "\U0001F9D1\u200d\U0001F4BB"
FLAG = "\U0001F1F3\U0001F1F1"
def _session_reactions():
_counter_reactions[0] += 1
name = f"rxn{int(time.time() * 1000)}{_counter_reactions[0]}"
@@ -251,12 +255,68 @@ def test_two_distinct_emoji_both_counted(app_server):
assert set(payload["mine"]) == {ROCKET, HEART}
def test_emoji_outside_palette_rejected(app_server):
def test_emoji_outside_palette_accepted(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": "notanemoji"},
data={"emoji": UNICORN},
headers=AJAX_reactions,
)
payload = r.json()
assert payload["counts"][UNICORN] == 1
assert payload["mine"] == [UNICORN]
assert _live_reaction_count("post", post_uid) == 1
def test_composed_emoji_accepted(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
for value in (SKIN_TONE, ZWJ_SEQUENCE, FLAG):
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": value},
headers=AJAX_reactions,
)
assert r.json()["counts"][value] == 1
assert _live_reaction_count("post", post_uid) == 3
def test_off_palette_reaction_toggles_off(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
for _ in range(2):
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": UNICORN},
headers=AJAX_reactions,
)
payload = r.json()
assert payload["counts"].get(UNICORN, 0) == 0
assert payload["mine"] == []
assert _live_reaction_count("post", post_uid) == 0
def test_non_emoji_rejected(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
for value in ("notanemoji", "<script>alert(1)</script>", " ", f"{UNICORN} text"):
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": value},
headers=AJAX_reactions,
allow_redirects=False,
)
assert r.status_code == 303, value
assert get_table("reactions").count(target_type="post", target_uid=post_uid) == 0
def test_multiple_emoji_in_one_reaction_rejected(app_server):
s, name = _session_reactions()
post_uid = _make_post_reactions(_uid_reactions(name))
r = s.post(
f"{BASE_URL}/reactions/post/{post_uid}",
data={"emoji": UNICORN + ROCKET},
headers=AJAX_reactions,
allow_redirects=False,
)
+33
View File
@@ -3,6 +3,8 @@
import re
from playwright.sync_api import expect
from tests.conftest import BASE_URL
UNICORN = "\U0001F984"
def _open_composer(page):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
@@ -37,3 +39,34 @@ def test_reaction_palette_toggle_and_react(alice):
expect(
page.locator(".reaction-chip.reacted").first.locator(".reaction-count")
).to_have_text("1")
def test_reaction_with_an_emoji_outside_the_palette(alice):
page, _ = alice
_create_plain_post_engagement_ui(page, "Post to react to with an off-palette emoji.")
page.locator(".reaction-add-btn").first.click()
page.locator(".reaction-palette-more").first.click()
picker = page.locator(".reaction-picker").first
picker.wait_for(state="visible")
picker.locator("emoji-picker").wait_for(state="attached")
page.evaluate(
"""(unicode) => {
const picker = document.querySelector(".reaction-picker emoji-picker");
picker.dispatchEvent(new CustomEvent("emoji-click", { detail: { unicode } }));
}""",
UNICORN,
)
chip = page.locator(f'.reaction-chip[data-reaction-emoji="{UNICORN}"]')
expect(chip).to_be_visible()
expect(chip).to_have_class(re.compile("reacted"))
expect(chip.locator(".reaction-count")).to_have_text("1")
expect(picker).to_be_hidden()
page.reload(wait_until="domcontentloaded")
reloaded = page.locator(f'.reaction-chip[data-reaction-emoji="{UNICORN}"]')
expect(reloaded).to_be_visible()
expect(reloaded.locator(".reaction-count")).to_have_text("1")
+4 -2
View File
@@ -148,7 +148,7 @@ def test_owner_sees_harvest_not_steal(alice):
assert page.locator("form[data-game-action='steal']").count() == 0
def test_victim_gets_anonymous_notification(bob):
def test_victim_notification_names_the_raider_and_the_amount(bob):
page, _ = bob
from devplacepy.database import get_table
@@ -172,4 +172,6 @@ def test_victim_gets_anonymous_notification(bob):
user_uid=alice["uid"], type="harvest_stolen"
)
assert note is not None
assert "bob_test" not in note["message"]
assert "bob_test" in note["message"]
assert "Python Script" in note["message"]
assert "harvest it" in note["message"]
+27 -15
View File
@@ -75,8 +75,18 @@ def open_game(page):
page.wait_for_timeout(800)
def hud_number(value):
from devplacepy.templating import format_number
return format_number(value)
def coins(page):
return int(page.locator("[data-hud-coins]").inner_text())
node = page.locator("[data-hud-coins]")
exact = node.get_attribute("title")
if exact:
return int(exact.replace(",", ""))
return int(node.inner_text().replace(",", ""))
def test_game_page_loads(alice):
@@ -84,7 +94,7 @@ def test_game_page_loads(alice):
reset_farm("alice_test")
open_game(page)
assert page.locator(".game-header h1").is_visible()
expect(page.locator("[data-hud-coins]")).to_have_text("100000")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(100000))
expect(page.locator("[data-game-grid] [data-slot]")).to_have_count(4)
expect(page.locator("[data-perk-host] .perk-card")).to_have_count(4)
expect(page.locator(".quest-item")).to_have_count(3)
@@ -104,7 +114,7 @@ def test_plant_crop(alice):
form.locator("select[name='crop']").select_option("shell")
form.locator("button").click()
expect(page.locator(".game-plot-growing")).to_have_count(1)
expect(page.locator("[data-hud-coins]")).to_have_text("99995")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99995))
def test_harvest_crop(alice):
@@ -118,7 +128,7 @@ def test_harvest_crop(alice):
warp_ready("alice_test")
open_game(page)
page.locator("form[data-game-action='harvest'] button").first.click()
expect(page.locator("[data-hud-coins]")).not_to_have_text("99995")
expect(page.locator("[data-hud-coins]")).not_to_have_text(hud_number(99995))
assert coins(page) > 99995
expect(page.locator(".game-plot-growing")).to_have_count(0)
@@ -130,11 +140,11 @@ def test_fertilize_spends_coins(alice):
form = page.locator("form[data-game-action='plant']").first
form.locator("select[name='crop']").select_option("python")
form.locator("button").click()
expect(page.locator("[data-hud-coins]")).to_have_text("99985")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99985))
fert = page.locator("form[data-game-action='fertilize'] button").first
fert.wait_for(state="visible")
fert.click()
expect(page.locator("[data-hud-coins]")).not_to_have_text("99985")
expect(page.locator("[data-hud-coins]")).not_to_have_text(hud_number(99985))
assert coins(page) < 99985
@@ -144,7 +154,7 @@ def test_buy_plot(alice):
open_game(page)
page.locator("form[data-game-action='buy-plot'] button").click()
expect(page.locator("[data-game-grid] [data-slot]")).to_have_count(5)
expect(page.locator("[data-hud-coins]")).to_have_text("99900")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99900))
def test_upgrade_ci(alice):
@@ -153,7 +163,7 @@ def test_upgrade_ci(alice):
open_game(page)
page.locator("form[data-game-action='upgrade'] button").click()
expect(page.locator("[data-hud-ci]")).to_have_text("2")
expect(page.locator("[data-hud-coins]")).to_have_text("99850")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99850))
def test_claim_daily(alice):
@@ -161,7 +171,7 @@ def test_claim_daily(alice):
reset_farm("alice_test")
open_game(page)
page.locator("form[data-game-action='daily'] button").click()
expect(page.locator("[data-hud-coins]")).to_have_text("100020")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(100020))
expect(page.locator("[data-daily-host] .daily-claimed")).to_be_visible()
@@ -173,7 +183,7 @@ def test_upgrade_perk(alice):
"form[data-game-action='perk']:has(input[value='growth']) button"
)
growth.click()
expect(page.locator("[data-hud-coins]")).to_have_text("99850")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(99850))
expect(
page.locator(".perk-card:has-text('Build Cache') .perk-level")
).to_have_text("Lv 1/10")
@@ -217,7 +227,7 @@ def test_claim_quest(alice):
)
open_game(page)
page.locator("form[data-game-action='claim-quest'] button").first.click()
expect(page.locator("[data-hud-coins]")).not_to_have_text("100000")
expect(page.locator("[data-hud-coins]")).not_to_have_text(hud_number(100000))
assert coins(page) > 100000
@@ -246,7 +256,7 @@ def test_prestige_resets_farm(alice):
confirm_btn.click()
expect(page.locator("[data-hud-prestige]")).to_have_text("1")
expect(page.locator("[data-hud-coins]")).to_have_text(
str(economy.STARTING_COINS + carried)
hud_number(economy.STARTING_COINS + carried)
)
assert page.locator("form[data-game-action='prestige']").count() == 0
@@ -283,7 +293,7 @@ def test_grant_section_claim(alice):
claim.wait_for(state="visible")
claim.click()
expect(page.locator("[data-hud-coins]")).to_have_text(
str(100 + economy.GRANT_CAP)
hud_number(100 + economy.GRANT_CAP)
)
assert page.locator("form[data-game-action='grant']").count() == 0
@@ -324,7 +334,9 @@ def test_infrastructure_buy_succeeds_when_eligible(alice):
confirm_btn.wait_for(state="visible")
confirm_btn.click()
expect(page.locator("form[data-game-action='infrastructure']")).to_have_count(0)
assert coins(page) == 100_000_000 - 25_000_000
from devplacepy.services.game import economy
assert coins(page) == 100_000_000 - economy.infra_for("registry").cost
def test_defense_upgrade_spends_coins(alice):
@@ -334,7 +346,7 @@ def test_defense_upgrade_spends_coins(alice):
upgrade = page.locator("form[data-game-action='defense'] button").first
upgrade.wait_for(state="visible")
upgrade.click()
expect(page.locator("[data-hud-coins]")).to_have_text("95000")
expect(page.locator("[data-hud-coins]")).to_have_text(hud_number(95000))
def test_cosmetics_buy_and_equip_flow(alice):
+26
View File
@@ -123,3 +123,29 @@ def test_create_notification_honors_in_app_pref(bob):
assert notifications.find_one(user_uid=uid, message=delivered) is not None
finally:
reset_notification_prefs(uid)
def test_quiz_attempt_type_listed_in_prefs(bob):
_, user = bob
uid = _user_notification_prefs(user["username"])["uid"]
reset_notification_prefs(uid)
prefs = get_notification_prefs(uid)
assert any(pref["key"] == "quiz_attempt" for pref in prefs)
def test_create_notification_honors_quiz_attempt_pref(bob):
_, user = bob
uid = _user_notification_prefs(user["username"])["uid"]
notifications = get_table("notifications")
reset_notification_prefs(uid)
try:
set_notification_pref(uid, "quiz_attempt", "in_app", False)
suppressed = f"quiz suppressed {generate_uid()}"
create_notification(uid, "quiz_attempt", suppressed, uid, "/quizzes")
assert notifications.find_one(user_uid=uid, message=suppressed) is None
set_notification_pref(uid, "quiz_attempt", "in_app", True)
delivered = f"quiz delivered {generate_uid()}"
create_notification(uid, "quiz_attempt", delivered, uid, "/quizzes")
assert notifications.find_one(user_uid=uid, message=delivered) is not None
finally:
reset_notification_prefs(uid)
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+174
View File
@@ -0,0 +1,174 @@
# retoor <retoor@molodetz.nl>
from tests.conftest import BASE_URL
KIND_PICKER = "select[data-quiz-kind-picker]"
DIALOG_MESSAGE = ".dialog-message"
DIALOG_CONFIRM = ".dialog-confirm"
DIALOG_CANCEL = ".dialog-cancel"
OPTION_FIELDS = "[data-quiz-kind-fields='options']"
FREE_TEXT_FIELDS = "[data-quiz-kind-fields='free_text']"
NUMERIC_FIELDS = "[data-quiz-kind-fields='numeric']"
BOOLEAN_FIELDS = "[data-quiz-kind-fields='true_false']"
PUBLISH_BUTTON = ".quiz-publish button:has-text('Publish quiz')"
def _draft(page, title):
page.goto(f"{BASE_URL}/quizzes/new", wait_until="domcontentloaded")
page.locator("form.quiz-form").wait_for(state="visible")
page.fill("input[name='title']", title)
page.fill("textarea[name='description']", "Built in the browser.")
page.click("form.quiz-form button[type='submit']")
page.wait_for_url("**/edit", wait_until="domcontentloaded")
return page.url.rsplit("/", 2)[-2]
def _add_choice(page, prompt="Which journal mode allows concurrent readers?"):
form = page.locator("form[data-quiz-question-form]")
form.wait_for(state="visible")
page.select_option(KIND_PICKER, "single_choice")
page.fill("form[data-quiz-question-form] textarea[name='prompt']", prompt)
page.fill("form[data-quiz-question-form] textarea[name='options']", "DELETE\nWAL")
page.fill("form[data-quiz-question-form] input[name='correct_indexes']", "1")
page.click("form[data-quiz-question-form] button[type='submit']")
page.locator(".quiz-question").first.wait_for(state="visible")
def test_the_builder_opens_after_creating_a_draft(alice):
page, _ = alice
_draft(page, "Builder opens quiz")
page.locator(".quiz-builder-page").wait_for(state="visible")
page.locator("form[data-quiz-question-form]").wait_for(state="visible")
def test_the_kind_picker_lists_all_eight_kinds(alice):
page, _ = alice
_draft(page, "Builder kinds quiz")
picker = page.locator(KIND_PICKER)
picker.wait_for(state="visible")
assert picker.locator("option").count() == 8
def test_the_kind_picker_swaps_the_visible_fields(alice):
page, _ = alice
_draft(page, "Builder field swap quiz")
page.locator(KIND_PICKER).wait_for(state="visible")
page.select_option(KIND_PICKER, "single_choice")
assert page.locator(OPTION_FIELDS).is_visible()
assert not page.locator(FREE_TEXT_FIELDS).is_visible()
page.select_option(KIND_PICKER, "free_text")
assert page.locator(FREE_TEXT_FIELDS).is_visible()
assert not page.locator(OPTION_FIELDS).is_visible()
page.select_option(KIND_PICKER, "numeric")
assert page.locator(NUMERIC_FIELDS).is_visible()
page.select_option(KIND_PICKER, "true_false")
assert page.locator(BOOLEAN_FIELDS).is_visible()
def test_adding_a_question_renders_it_in_the_list(alice):
page, _ = alice
_draft(page, "Builder add quiz")
_add_choice(page)
question = page.locator(".quiz-question").first
assert "Which journal mode" in question.inner_text()
assert "Single choice" in question.inner_text()
def test_the_preview_marks_the_correct_option(alice):
page, _ = alice
_draft(page, "Builder preview quiz")
_add_choice(page)
page.locator(".quiz-preview-correct").first.wait_for(state="visible")
assert page.locator(".quiz-preview-correct").first.inner_text().strip() == "WAL"
def test_the_checklist_blocks_publishing_an_empty_quiz(alice):
page, _ = alice
_draft(page, "Builder checklist quiz")
page.locator(".quiz-checklist").wait_for(state="visible")
assert page.locator(PUBLISH_BUTTON).is_disabled()
def test_the_checklist_clears_once_the_quiz_is_complete(alice):
page, _ = alice
_draft(page, "Builder ready quiz")
_add_choice(page)
page.locator(".quiz-publish-ready").wait_for(state="visible")
assert page.locator(PUBLISH_BUTTON).is_enabled()
def test_the_move_controls_reorder_the_questions(alice):
page, _ = alice
_draft(page, "Builder reorder quiz")
_add_choice(page, "First question")
_add_choice(page, "Second question")
before = page.locator(".quiz-question-prompt").first.inner_text()
page.locator("button[aria-label='Move question down']").first.click()
page.wait_for_url("**/edit", wait_until="domcontentloaded")
page.locator(".quiz-question").first.wait_for(state="visible")
after = page.locator(".quiz-question-prompt").first.inner_text()
assert after != before
def test_deleting_a_question_removes_it(alice):
page, _ = alice
_draft(page, "Builder delete quiz")
_add_choice(page, "Doomed question")
assert page.locator(".quiz-question").count() == 1
page.locator(".quiz-question-actions button:has-text('Delete')").first.click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url("**/edit", wait_until="domcontentloaded")
page.locator(".empty-state").wait_for(state="visible")
assert page.locator(".quiz-question").count() == 0
def test_publishing_asks_for_confirmation(alice):
page, _ = alice
slug = _draft(page, "Builder confirm quiz")
_add_choice(page)
page.locator(PUBLISH_BUTTON).click()
page.locator(DIALOG_MESSAGE).wait_for(state="visible")
assert "permanent" in page.locator(DIALOG_MESSAGE).inner_text().lower()
page.locator(DIALOG_CANCEL).click()
page.goto(f"{BASE_URL}/quizzes/{slug}/edit", wait_until="domcontentloaded")
page.locator(".quiz-publish").wait_for(state="visible")
assert page.locator(".quiz-banner-locked").count() == 0
def test_accepting_the_confirmation_publishes(alice):
page, _ = alice
slug = _draft(page, "Builder publish quiz")
_add_choice(page)
page.locator(PUBLISH_BUTTON).click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url(f"**/quizzes/{slug}", wait_until="domcontentloaded")
page.locator("h1.quiz-detail-title").wait_for(state="visible")
def test_the_builder_is_read_only_after_publishing(alice):
page, _ = alice
slug = _draft(page, "Builder frozen quiz")
_add_choice(page)
page.locator(PUBLISH_BUTTON).click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url(f"**/quizzes/{slug}", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/quizzes/{slug}/edit", wait_until="domcontentloaded")
page.locator(".quiz-banner-locked").wait_for(state="visible")
assert page.locator("form[data-quiz-question-form]").count() == 0
assert page.locator(PUBLISH_BUTTON).count() == 0
assert page.locator(".quiz-question-actions").count() == 0
def test_the_detail_page_hides_edit_after_publishing(alice):
page, _ = alice
slug = _draft(page, "Builder detail frozen quiz")
_add_choice(page)
page.locator(PUBLISH_BUTTON).click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url(f"**/quizzes/{slug}", wait_until="domcontentloaded")
page.locator(".quiz-detail-actions").wait_for(state="visible")
assert page.locator(".quiz-detail-actions a:has-text('Edit')").count() == 0
+194
View File
@@ -0,0 +1,194 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
LAYOUT = ".feed-layout"
LEFT = ".feed-layout > .sidebar-card"
CENTRE = ".feed-layout > .feed-main"
RIGHT = ".feed-layout > .feed-right"
NAV_ENTRY = ".topnav-link[href='/quizzes']"
NEW_QUIZ_BUTTON = ".quiz-actions a[href='/quizzes/new']"
DEVII_BUTTON = ".quiz-actions button[data-devii-open]"
SCOREBOARD = ".quiz-scoreboard-card"
def _seed_quiz(title="Hub e2e quiz"):
session = requests.Session()
name = f"hub{abs(hash(title)) % 10**9}"
session.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
slug = session.post(
f"{BASE_URL}/quizzes/create", data={"title": title, "description": "d"}, headers=JSON
).json()["data"]["slug"]
session.post(
f"{BASE_URL}/quizzes/{slug}/questions",
data={
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 2,
"options": "DELETE\nWAL",
"correct_indexes": "1",
},
headers=JSON,
)
session.post(f"{BASE_URL}/quizzes/{slug}/publish", headers=JSON)
return slug
def test_the_hub_renders_the_three_column_layout(page, app_server):
_seed_quiz("Layout e2e quiz")
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(LAYOUT).wait_for(state="visible")
assert page.locator(LEFT).count() == 1
assert page.locator(CENTRE).count() == 1
assert page.locator(RIGHT).count() == 1
def test_the_hub_has_a_visible_heading(page, app_server):
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
heading = page.locator("h1.quiz-page-title")
heading.wait_for(state="visible")
assert heading.inner_text().strip() == "Quizzes"
def test_the_nav_entry_is_visible_to_a_guest_and_active(page, app_server):
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
entry = page.locator(NAV_ENTRY)
entry.wait_for(state="visible")
assert "active" in (entry.get_attribute("class") or "")
def test_a_guest_sees_disabled_create_controls(page, app_server):
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(".quiz-actions").wait_for(state="visible")
assert page.locator(NEW_QUIZ_BUTTON).get_attribute("aria-disabled") == "true"
assert page.locator(DEVII_BUTTON).is_disabled()
def test_a_guest_still_sees_the_list_and_the_scoreboard(page, app_server):
_seed_quiz("Guest visible quiz")
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(SCOREBOARD).wait_for(state="visible")
assert page.locator(".quiz-card").count() >= 1
def test_a_member_sees_enabled_create_controls(alice):
page, _ = alice
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(NEW_QUIZ_BUTTON).wait_for(state="visible")
assert page.locator(NEW_QUIZ_BUTTON).get_attribute("aria-disabled") is None
assert page.locator(DEVII_BUTTON).is_enabled()
def test_a_member_sees_the_personal_filters(alice):
page, _ = alice
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(".sidebar-nav").wait_for(state="visible")
for target in ("todo", "done", "mine", "drafts"):
assert page.locator(f".sidebar-link[href='/quizzes?filter={target}']").count() == 1
def test_a_guest_sees_only_the_all_filter(page, app_server):
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(".sidebar-nav").wait_for(state="visible")
assert page.locator(".sidebar-link[href='/quizzes?filter=todo']").count() == 0
assert page.locator(".sidebar-link[href='/quizzes?filter=all']").count() == 1
def test_a_quiz_card_shows_its_state_badge(alice):
page, _ = alice
slug = _seed_quiz("Badge e2e quiz")
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
card = page.locator(f".quiz-card:has(a[href='/quizzes/{slug}'])").first
card.wait_for(state="visible")
assert card.locator(".quiz-state-todo").count() == 1
def test_a_quiz_card_shows_its_meta_chips(page, app_server):
slug = _seed_quiz("Meta chips e2e quiz")
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
card = page.locator(f".quiz-card:has(a[href='/quizzes/{slug}'])").first
card.wait_for(state="visible")
assert "1 questions" in card.inner_text()
assert "2 points" in card.inner_text()
def test_the_search_box_filters_the_list(page, app_server):
slug = _seed_quiz("Unmistakable pelican quiz")
page.goto(f"{BASE_URL}/quizzes?search=pelican", wait_until="domcontentloaded")
page.locator(".feed-posts").wait_for(state="visible")
assert page.locator(f".quiz-card:has(a[href='/quizzes/{slug}'])").count() == 1
def test_the_new_quiz_button_opens_the_create_form(alice):
page, _ = alice
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(NEW_QUIZ_BUTTON).click()
page.wait_for_url("**/quizzes/new", wait_until="domcontentloaded")
page.locator("form.quiz-form").wait_for(state="visible")
def test_the_devii_button_seeds_the_terminal_without_sending(alice):
page, _ = alice
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
trigger = page.locator(DEVII_BUTTON)
trigger.wait_for(state="visible")
prompt = trigger.get_attribute("data-devii-prompt")
assert prompt and "import_quiz" in prompt
trigger.click()
terminal_input = page.locator("devii-terminal .devii-input")
terminal_input.wait_for(state="visible", timeout=10000)
assert terminal_input.input_value() == prompt
assert page.locator("devii-terminal .devii-line-user").count() == 0
page.evaluate("window.app && window.app.devii && window.app.devii.close()")
page.evaluate("window.localStorage.clear()")
def test_opening_a_card_reaches_the_detail_page(page, app_server):
slug = _seed_quiz("Card link e2e quiz")
page.goto(f"{BASE_URL}/quizzes", wait_until="domcontentloaded")
page.locator(f".quiz-card a.card-link[href='/quizzes/{slug}']").first.click()
page.wait_for_url(f"**/quizzes/{slug}", wait_until="domcontentloaded")
page.locator("h1.quiz-detail-title").wait_for(state="visible")
def test_the_detail_page_shows_the_stats_strip(page, app_server):
slug = _seed_quiz("Stats strip e2e quiz")
page.goto(f"{BASE_URL}/quizzes/{slug}", wait_until="domcontentloaded")
page.locator(".quiz-stats-strip").wait_for(state="visible")
assert page.locator(".quiz-stat").count() == 5
def test_the_detail_page_offers_a_start_button_to_a_member(alice):
page, _ = alice
slug = _seed_quiz("Start button e2e quiz")
page.goto(f"{BASE_URL}/quizzes/{slug}", wait_until="domcontentloaded")
button = page.locator(".quiz-detail-actions button:has-text('Start quiz')")
button.wait_for(state="visible")
assert button.is_enabled()
def test_the_detail_page_disables_start_for_a_guest(page, app_server):
slug = _seed_quiz("Guest start e2e quiz")
page.goto(f"{BASE_URL}/quizzes/{slug}", wait_until="domcontentloaded")
button = page.locator(".quiz-detail-actions button:has-text('Start quiz')")
button.wait_for(state="visible")
assert button.is_disabled()
def test_the_detail_page_carries_a_comment_section(page, app_server):
slug = _seed_quiz("Comment section e2e quiz")
page.goto(f"{BASE_URL}/quizzes/{slug}", wait_until="domcontentloaded")
page.locator(".comments-section").wait_for(state="visible")
+276
View File
@@ -0,0 +1,276 @@
# retoor <retoor@molodetz.nl>
import requests
from tests.conftest import BASE_URL
JSON = {"Accept": "application/json"}
CHOICE = {
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 2,
"options": "DELETE\nWAL",
"correct_indexes": "1",
}
NUMERIC = {
"kind": "numeric",
"prompt": "How many bytes are in a kibibyte?",
"points": 1,
"numeric_value": "1024",
"numeric_tolerance": "0",
}
DIALOG_CONFIRM = ".dialog-confirm"
_counter = [0]
def _author_quiz(title, questions=(CHOICE, NUMERIC), **settings):
_counter[0] += 1
name = f"play{abs(hash(title)) % 10**8}{_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,
)
payload = {"title": title, "description": "d", **settings}
slug = session.post(f"{BASE_URL}/quizzes/create", data=payload, headers=JSON).json()["data"][
"slug"
]
for question in questions:
session.post(f"{BASE_URL}/quizzes/{slug}/questions", data=question, headers=JSON)
session.post(f"{BASE_URL}/quizzes/{slug}/publish", headers=JSON)
return slug
def _start(page, slug):
page.goto(f"{BASE_URL}/quizzes/{slug}", wait_until="domcontentloaded")
page.locator(".quiz-detail-actions button:has-text('Start quiz')").click()
page.wait_for_url("**/attempts/**", wait_until="domcontentloaded")
page.locator("dp-quiz-player").wait_for(state="visible")
def test_starting_opens_the_player(alice):
page, _ = alice
slug = _author_quiz("Player opens quiz")
_start(page, slug)
assert page.locator(".quiz-question").count() == 2
page.locator(".quiz-hud").wait_for(state="visible")
def test_the_player_renders_a_real_form_per_question(alice):
page, _ = alice
slug = _author_quiz("Player forms quiz")
_start(page, slug)
forms = page.locator("form[data-quiz-answer-form]")
assert forms.count() == 2
assert forms.first.get_attribute("method").lower() == "post"
def test_answering_a_choice_shows_the_grade_in_place(alice):
page, _ = alice
slug = _author_quiz("Player grade quiz")
_start(page, slug)
question = page.locator(".quiz-question-single_choice").first
question.locator("input[type='radio']").last.check()
question.locator("button:has-text('Submit answer')").click()
question.locator(".quiz-grade-correct").wait_for(state="visible")
assert "Correct" in question.locator(".quiz-grade-verdict").inner_text()
def test_a_wrong_answer_is_reported(alice):
page, _ = alice
slug = _author_quiz("Player wrong quiz")
_start(page, slug)
question = page.locator(".quiz-question-single_choice").first
question.locator("input[type='radio']").first.check()
question.locator("button:has-text('Submit answer')").click()
question.locator(".quiz-grade-wrong").wait_for(state="visible")
def test_the_hud_updates_after_an_answer(alice):
page, _ = alice
slug = _author_quiz("Player hud quiz")
_start(page, slug)
before = page.locator("[data-quiz-progress]").inner_text()
question = page.locator(".quiz-question-single_choice").first
question.locator("input[type='radio']").last.check()
question.locator("button:has-text('Submit answer')").click()
question.locator(".quiz-grade").wait_for(state="visible")
page.wait_for_function(
"before => document.querySelector('[data-quiz-progress]').textContent !== before",
arg=before,
)
assert "1 / 2 answered" in page.locator("[data-quiz-progress]").inner_text()
assert "2 / 3 points" in page.locator("[data-quiz-score]").inner_text()
def test_an_answered_question_cannot_be_resubmitted(alice):
page, _ = alice
slug = _author_quiz("Player once quiz")
_start(page, slug)
question = page.locator(".quiz-question-single_choice").first
question.locator("input[type='radio']").last.check()
question.locator("button:has-text('Submit answer')").click()
question.locator(".quiz-grade").wait_for(state="visible")
assert question.locator("button:has-text('Submit answer')").count() == 0
assert question.locator("input[type='radio']").first.is_disabled()
def test_a_numeric_question_takes_a_typed_answer(alice):
page, _ = alice
slug = _author_quiz("Player numeric quiz")
_start(page, slug)
question = page.locator(".quiz-question-numeric").first
question.locator("input[name='answer_text']").fill("1024")
question.locator("button:has-text('Submit answer')").click()
question.locator(".quiz-grade-correct").wait_for(state="visible")
def test_the_timer_is_rendered_for_a_limited_quiz(alice):
page, _ = alice
slug = _author_quiz("Player timer quiz", time_limit_seconds="600")
_start(page, slug)
timer = page.locator("[data-quiz-timer]")
timer.wait_for(state="visible")
assert timer.inner_text().strip()
def test_no_timer_is_rendered_without_a_limit(alice):
page, _ = alice
slug = _author_quiz("Player untimed quiz")
_start(page, slug)
page.locator(".quiz-hud").wait_for(state="visible")
assert page.locator("[data-quiz-timer]").count() == 0
def test_the_answer_key_is_not_in_the_page_source(alice):
page, _ = alice
slug = _author_quiz("Player hidden key quiz", questions=(NUMERIC,), reveal_answers="0")
_start(page, slug)
assert "1024" not in page.content()
def test_reloading_resumes_the_same_attempt(alice):
page, _ = alice
slug = _author_quiz("Player resume quiz")
_start(page, slug)
attempt_url = page.url
question = page.locator(".quiz-question-single_choice").first
question.locator("input[type='radio']").last.check()
question.locator("button:has-text('Submit answer')").click()
question.locator(".quiz-grade").wait_for(state="visible")
page.goto(f"{BASE_URL}/quizzes/{slug}", wait_until="domcontentloaded")
page.locator(".quiz-detail-actions a:has-text('Resume attempt')").click()
page.wait_for_url("**/attempts/**", wait_until="domcontentloaded")
assert page.url == attempt_url
assert page.locator(".quiz-question-answered").count() == 1
def test_finishing_reaches_the_results_page(alice):
page, _ = alice
slug = _author_quiz("Player finish quiz", pass_percent="50")
_start(page, slug)
for question in (".quiz-question-single_choice", ".quiz-question-numeric"):
node = page.locator(question).first
if "numeric" in question:
node.locator("input[name='answer_text']").fill("1024")
else:
node.locator("input[type='radio']").last.check()
node.locator("button:has-text('Submit answer')").click()
node.locator(".quiz-grade").wait_for(state="visible")
page.locator("form[data-quiz-finish-form] button").click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url("**/results", wait_until="domcontentloaded")
page.locator(".quiz-score-card").wait_for(state="visible")
assert "100" in page.locator(".quiz-score-percent").inner_text()
def test_the_results_page_shows_the_pass_verdict(alice):
page, _ = alice
slug = _author_quiz("Player verdict quiz", questions=(NUMERIC,), pass_percent="50")
_start(page, slug)
node = page.locator(".quiz-question-numeric").first
node.locator("input[name='answer_text']").fill("1024")
node.locator("button:has-text('Submit answer')").click()
node.locator(".quiz-grade").wait_for(state="visible")
page.locator("form[data-quiz-finish-form] button").click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url("**/results", wait_until="domcontentloaded")
page.locator(".quiz-verdict-pass").wait_for(state="visible")
def test_the_results_page_reviews_every_answer(alice):
page, _ = alice
slug = _author_quiz("Player review quiz", allow_review="1")
_start(page, slug)
node = page.locator(".quiz-question-single_choice").first
node.locator("input[type='radio']").last.check()
node.locator("button:has-text('Submit answer')").click()
node.locator(".quiz-grade").wait_for(state="visible")
page.locator("form[data-quiz-finish-form] button").click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url("**/results", wait_until="domcontentloaded")
page.locator(".quiz-review").wait_for(state="visible")
assert page.locator(".quiz-review-item").count() == 2
def test_the_review_is_hidden_when_the_author_disabled_it(alice):
page, _ = alice
slug = _author_quiz("Player no review quiz", questions=(NUMERIC,), allow_review="0")
_start(page, slug)
node = page.locator(".quiz-question-numeric").first
node.locator("input[name='answer_text']").fill("1024")
node.locator("button:has-text('Submit answer')").click()
node.locator(".quiz-grade").wait_for(state="visible")
page.locator("form[data-quiz-finish-form] button").click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url("**/results", wait_until="domcontentloaded")
page.locator(".quiz-review-disabled").wait_for(state="visible")
def test_the_results_page_offers_a_replay(alice):
page, _ = alice
slug = _author_quiz("Player replay quiz", questions=(NUMERIC,))
_start(page, slug)
node = page.locator(".quiz-question-numeric").first
node.locator("input[name='answer_text']").fill("1024")
node.locator("button:has-text('Submit answer')").click()
node.locator(".quiz-grade").wait_for(state="visible")
page.locator("form[data-quiz-finish-form] button").click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url("**/results", wait_until="domcontentloaded")
page.locator(".quiz-results-actions button:has-text('Play again')").click()
page.wait_for_url("**/attempts/**", wait_until="domcontentloaded")
page.locator("dp-quiz-player").wait_for(state="visible")
def test_the_hub_shows_the_completed_badge_afterwards(alice):
page, _ = alice
slug = _author_quiz("Player badge quiz", questions=(NUMERIC,))
_start(page, slug)
node = page.locator(".quiz-question-numeric").first
node.locator("input[name='answer_text']").fill("1024")
node.locator("button:has-text('Submit answer')").click()
node.locator(".quiz-grade").wait_for(state="visible")
page.locator("form[data-quiz-finish-form] button").click()
page.locator(DIALOG_CONFIRM).wait_for(state="visible")
page.locator(DIALOG_CONFIRM).click()
page.wait_for_url("**/results", wait_until="domcontentloaded")
page.goto(f"{BASE_URL}/quizzes?filter=done", wait_until="domcontentloaded")
card = page.locator(f".quiz-card:has(a[href='/quizzes/{slug}'])").first
card.wait_for(state="visible")
assert card.locator(".quiz-state-done").count() == 1
+1 -1
View File
@@ -189,7 +189,7 @@ def test_owns_instance_true_for_creator():
owner = {"uid": "cnt-u1"}
other = {"uid": "cnt-u2"}
instance = {"created_by": owner["uid"]}
project = {"user_uid": other["uid"]}
project = {"user_uid": "cnt-u3"}
assert owns_instance(instance, project, owner) is True
assert owns_instance(instance, project, other) is False
+64
View File
@@ -45,3 +45,67 @@ def test_isslop_run_form_normalizes_typos_and_bare_domains():
)
assert IsslopRunForm(url="example.com/owner/repo").url == "https://example.com/owner/repo"
assert IsslopRunForm(url="http:/x.dev/a").url == "http://x.dev/a"
def test_reaction_form_accepts_any_single_emoji():
from devplacepy.models import ReactionForm
for value in (
"\U0001F44D",
"\U0001F984",
"\u2764\uFE0F",
"\U0001F44D\U0001F3FD",
"\U0001F9D1\u200d\U0001F4BB",
"\U0001F3F3\uFE0F\u200d\U0001F308",
"\U0001F1F3\U0001F1F1",
"1\uFE0F\u20E3",
):
assert ReactionForm(emoji=value).emoji == value
def test_reaction_form_accepts_every_emoji_the_picker_offers():
import json
from pathlib import Path
from devplacepy.models import ReactionForm
data_path = (
Path(__file__).resolve().parents[2]
/ "devplacepy"
/ "static"
/ "vendor"
/ "emoji-picker-element"
/ "data.json"
)
entries = json.loads(data_path.read_text(encoding="utf-8"))
offered = [entry["emoji"] for entry in entries]
offered += [
skin["emoji"] for entry in entries for skin in (entry.get("skins") or [])
]
assert len(offered) > 3000
for value in offered:
assert ReactionForm(emoji=value).emoji == value
def test_reaction_form_strips_surrounding_whitespace():
from devplacepy.models import ReactionForm
assert ReactionForm(emoji=" \U0001F680 ").emoji == "\U0001F680"
def test_reaction_form_rejects_non_emoji_values():
from devplacepy.models import ReactionForm
for value in (
"notanemoji",
"a",
" ",
"",
"<script>",
"\u200b",
"\U0001F44D x",
"\U0001F44D\U0001F44D",
):
with pytest.raises(ValidationError):
ReactionForm(emoji=value)
@@ -119,3 +119,32 @@ def test_game_actions_registered_with_correct_auth():
def test_game_read_actions_are_read_only():
for name in ("game_state", "game_leaderboard", "game_view_farm"):
assert BY_NAME[name].is_read_only is True
def test_scheduling_tools_are_administrator_only():
from devplacepy.services.devii.registry import CATALOG
by_name = CATALOG.by_name()
for name in ("create_task", "update_task", "run_task_now"):
action = by_name[name]
assert action.requires_admin is True
assert action.requires_auth is True
for name in ("list_tasks", "get_task", "delete_task"):
assert by_name[name].requires_admin is False
def test_scheduling_tools_hidden_from_a_member_tool_list():
from devplacepy.services.devii.registry import CATALOG
member = {
s["function"]["name"]
for s in CATALOG.tool_schemas_for(authenticated=True, is_admin=False)
}
admin = {
s["function"]["name"]
for s in CATALOG.tool_schemas_for(authenticated=True, is_admin=True)
}
for name in ("create_task", "update_task", "run_task_now"):
assert name not in member
assert name in admin
assert "list_tasks" in member
+26 -4
View File
@@ -71,10 +71,32 @@ def test_guest_main_has_ui_prompt_without_pref_tools(local_db):
assert "interactions_set" not in names
def test_docs_channel_does_not_start_scheduler(local_db):
docs = _session("docs", "sess-docs-sched")
docs.attach(object())
assert docs._started is False
def test_scheduled_tasks_only_ever_resolve_a_main_channel_session(local_db):
hub, svc = _hub()
channels = []
original = hub.get_or_create
def capture(*args, **kwargs):
channels.append(kwargs.get("channel"))
return original(*args, **kwargs)
hub.get_or_create = capture
try:
row = {"owner_kind": "user", "owner_id": "sess-docs-sched"}
local_db["users"].insert(
{
"uid": "sess-docs-sched",
"username": "sched-owner",
"role": "Admin",
"api_key": "k",
"deleted_at": None,
}
)
database.invalidate_admins_cache()
svc._resolve_task_owner(row)
finally:
hub.get_or_create = original
assert channels == ["main"]
def test_user_docs_channel_uses_ephemeral_stores(local_db):
+95
View File
@@ -0,0 +1,95 @@
# retoor <retoor@molodetz.nl>
from datetime import timedelta
from devplacepy.database import invalidate_admins_cache
from devplacepy.services.devii.tasks.guards import (
REASON_BUDGET,
REASON_EXPIRED,
REASON_FAILURES,
REASON_MAX_RUNS,
REASON_NOT_ADMIN,
automation_allowed,
refusal,
)
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
from devplacepy.utils import generate_uid
def _account(local_db, role):
uid = generate_uid()
local_db["users"].insert(
{
"uid": uid,
"username": f"guard-{uid[-10:]}",
"role": role,
"deleted_at": None,
}
)
invalidate_admins_cache()
return uid
def _row(owner_uid, **overrides):
row = {
"uid": generate_uid(),
"owner_kind": "user",
"owner_id": owner_uid,
"run_count": 0,
"created_at": to_iso(now_utc()),
}
row.update(overrides)
return row
def test_only_administrators_may_automate(local_db):
admin = _account(local_db, "Admin")
member = _account(local_db, "Member")
assert automation_allowed("user", admin) is True
assert automation_allowed("user", member) is False
assert automation_allowed("guest", "guest-cookie") is False
assert automation_allowed("user", "") is False
def test_member_owned_task_is_refused(local_db):
member = _account(local_db, "Member")
assert refusal(_row(member), now_utc()) == REASON_NOT_ADMIN
def test_clean_admin_task_is_allowed(local_db):
admin = _account(local_db, "Admin")
assert refusal(_row(admin), now_utc()) is None
def test_exhausted_task_is_refused(local_db):
admin = _account(local_db, "Admin")
row = _row(admin, max_runs=5, run_count=5)
assert refusal(row, now_utc()) == REASON_MAX_RUNS
def test_expired_task_is_refused(local_db):
admin = _account(local_db, "Admin")
now = now_utc()
row = _row(admin, expires_at=to_iso(now - timedelta(minutes=1)))
assert refusal(row, now) == REASON_EXPIRED
def test_task_without_an_expiry_stops_at_the_fallback_ceiling(local_db):
admin = _account(local_db, "Admin")
now = now_utc()
row = _row(admin, created_at=to_iso(now - timedelta(days=40)))
assert refusal(row, now) == REASON_EXPIRED
def test_repeated_failures_refuse_the_task(local_db):
admin = _account(local_db, "Admin")
row = _row(admin, failure_count=3)
assert refusal(row, now_utc(), max_failures=3) == REASON_FAILURES
assert refusal(row, now_utc(), max_failures=0) is None
def test_budget_probe_refuses_the_task(local_db):
admin = _account(local_db, "Admin")
row = _row(admin)
assert refusal(row, now_utc(), budget_exceeded=lambda k, i: True) == REASON_BUDGET
assert refusal(row, now_utc(), budget_exceeded=lambda k, i: False) is None
@@ -0,0 +1,79 @@
# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.services.devii.tasks.guards import task_columns
from devplacepy.services.devii.tasks.schedule import (
DEFAULT_MAX_RUNS,
MAX_LIFETIME_DAYS,
MAX_MAX_RUNS,
MIN_INTERVAL_SECONDS,
Schedule,
from_iso,
now_utc,
)
def test_interval_below_the_floor_is_rejected():
with pytest.raises(ValueError):
Schedule(kind="interval", every_seconds=MIN_INTERVAL_SECONDS - 1)
with pytest.raises(ValueError):
Schedule(kind="interval", every_seconds=180)
def test_interval_at_the_floor_is_accepted():
schedule = Schedule(kind="interval", every_seconds=MIN_INTERVAL_SECONDS)
assert schedule.every_seconds == MIN_INTERVAL_SECONDS
def test_cron_firing_faster_than_the_floor_is_rejected():
for expression in ("* * * * *", "*/5 * * * *", "*/14 * * * *"):
with pytest.raises(ValueError):
Schedule(kind="cron", cron=expression)
def test_cron_with_an_uneven_step_is_rejected_for_its_boundary_hop():
with pytest.raises(ValueError):
Schedule(kind="cron", cron="*/18 * * * *")
def test_cron_at_or_above_the_floor_is_accepted():
for expression in ("*/15 * * * *", "0 * * * *", "0 22 * * *"):
assert Schedule(kind="cron", cron=expression).cron == expression
def test_max_runs_ceiling_is_enforced():
with pytest.raises(ValueError):
Schedule(kind="interval", every_seconds=900, max_runs=MAX_MAX_RUNS + 1)
with pytest.raises(ValueError):
Schedule(kind="interval", every_seconds=900, max_runs=0)
def test_recurring_task_without_max_runs_gets_the_default():
reference = now_utc()
columns = task_columns(Schedule(kind="interval", every_seconds=900), reference)
assert columns["max_runs"] == DEFAULT_MAX_RUNS
def test_one_shot_task_keeps_no_run_limit():
reference = now_utc()
columns = task_columns(Schedule(kind="once", delay_seconds=60), reference)
assert columns["max_runs"] is None
def test_every_task_gets_an_expiry_within_the_ceiling():
reference = now_utc()
columns = task_columns(Schedule(kind="interval", every_seconds=900), reference)
expiry = from_iso(columns["expires_at"])
assert expiry > reference
assert (expiry - reference).days <= MAX_LIFETIME_DAYS
def test_a_caller_cannot_extend_the_expiry_past_the_ceiling():
reference = now_utc()
far = reference.replace(year=reference.year + 5)
columns = task_columns(
Schedule(kind="interval", every_seconds=900, expires_at=far), reference
)
expiry = from_iso(columns["expires_at"])
assert (expiry - reference).days <= MAX_LIFETIME_DAYS
@@ -0,0 +1,194 @@
# retoor <retoor@molodetz.nl>
import asyncio
from devplacepy.database import invalidate_admins_cache
from devplacepy.services.devii.tasks.guards import (
REASON_EXPIRED,
REASON_MAX_RUNS,
REASON_NOT_ADMIN,
)
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
from devplacepy.services.devii.tasks.scheduler import GlobalScheduler
from devplacepy.services.devii.tasks.store import TaskStore
from devplacepy.utils import generate_uid
from tests.conftest import run_async
def _account(local_db, role):
uid = generate_uid()
local_db["users"].insert(
{
"uid": uid,
"username": f"sched-{uid[-10:]}",
"role": role,
"api_key": "k",
"deleted_at": None,
}
)
invalidate_admins_cache()
return uid
def _seed(local_db, owner, **overrides):
row = {
"uid": generate_uid(),
"owner_kind": "user",
"owner_id": owner,
"label": "scheduled",
"prompt": "work",
"enabled": True,
"status": "pending",
"kind": "interval",
"every_seconds": 900,
"max_runs": 10,
"run_count": 0,
"failure_count": 0,
"created_at": to_iso(now_utc()),
"next_run_at": to_iso(now_utc()),
"expires_at": None,
"deleted_at": None,
"deleted_by": None,
}
row.update(overrides)
local_db["devii_tasks"].insert(row)
return row["uid"]
def _harness(local_db, started, gate, peak=None):
active: dict[str, int] = {}
def resolve(row):
owner = str(row["owner_id"])
store = TaskStore(local_db, "user", owner)
async def executor(prompt):
started.append(owner)
active[owner] = active.get(owner, 0) + 1
if peak is not None:
peak.append(max(active.values()))
try:
await gate.wait()
return f"done: {prompt}"
finally:
active[owner] -= 1
return store, executor, lambda kind, task_row, payload: None
return resolve
def test_scheduler_runs_one_task_per_owner_and_completes_it(local_db):
local_db["devii_tasks"].delete()
owner = _account(local_db, "Admin")
first = _seed(local_db, owner)
second = _seed(local_db, owner)
started: list[str] = []
peak: list[int] = []
async def run():
gate = asyncio.Event()
scheduler = GlobalScheduler(
local_db, _harness(local_db, started, gate, peak), tick_seconds=0.05
)
scheduler.start()
await asyncio.sleep(0.4)
claimed = [r["uid"] for r in local_db["devii_tasks"].find(status="running")]
held = len(started)
gate.set()
await asyncio.sleep(0.5)
await scheduler.stop()
return claimed, held
claimed, held = run_async(run())
assert held == 1
assert max(peak) == 1
assert len(claimed) == 1
done = local_db["devii_tasks"].find_one(uid=claimed[0])
assert int(done["run_count"]) == 1
assert done["status"] == "pending"
assert done["last_result"].startswith("done:")
assert done["next_run_at"] > to_iso(now_utc())
other = second if claimed[0] == first else first
assert local_db["devii_tasks"].find_one(uid=other)["status"] == "pending"
def test_scheduler_retires_a_task_whose_owner_may_not_schedule(local_db):
local_db["devii_tasks"].delete()
uid = _seed(local_db, _account(local_db, "Member"))
started: list[str] = []
async def run():
gate = asyncio.Event()
gate.set()
scheduler = GlobalScheduler(
local_db, _harness(local_db, started, gate), tick_seconds=0.05
)
scheduler.start()
await asyncio.sleep(0.3)
await scheduler.stop()
run_async(run())
row = local_db["devii_tasks"].find_one(uid=uid)
assert not row["enabled"]
assert row["status"] == "disabled"
assert row["last_error"] == REASON_NOT_ADMIN
assert started == []
def test_scheduler_retires_expired_and_exhausted_tasks_even_while_saturated(local_db):
local_db["devii_tasks"].delete()
owner = _account(local_db, "Admin")
_seed(local_db, owner)
expired = _seed(
local_db, owner, expires_at=to_iso(now_utc().replace(year=now_utc().year - 1))
)
exhausted = _seed(local_db, owner, max_runs=3, run_count=3)
started: list[str] = []
async def run():
gate = asyncio.Event()
scheduler = GlobalScheduler(
local_db, _harness(local_db, started, gate), tick_seconds=0.05
)
scheduler.start()
await asyncio.sleep(0.4)
gate.set()
await asyncio.sleep(0.2)
await scheduler.stop()
run_async(run())
assert local_db["devii_tasks"].find_one(uid=expired)["last_error"] == REASON_EXPIRED
assert local_db["devii_tasks"].find_one(uid=exhausted)["last_error"] == REASON_MAX_RUNS
for uid in (expired, exhausted):
assert not local_db["devii_tasks"].find_one(uid=uid)["enabled"]
def test_scheduler_disables_a_task_after_repeated_failures(local_db):
local_db["devii_tasks"].delete()
owner = _account(local_db, "Admin")
uid = _seed(local_db, owner, failure_count=2)
def resolve(row):
store = TaskStore(local_db, "user", str(row["owner_id"]))
async def executor(prompt):
raise RuntimeError("upstream is down")
return store, executor, lambda kind, task_row, payload: None
async def run():
scheduler = GlobalScheduler(
local_db, resolve, tick_seconds=0.05, max_failures=3
)
scheduler.start()
await asyncio.sleep(0.4)
await scheduler.stop()
run_async(run())
row = local_db["devii_tasks"].find_one(uid=uid)
assert int(row["failure_count"]) == 3
assert not row["enabled"]
assert row["status"] == "error"
assert "upstream is down" in row["last_error"]
+136
View File
@@ -0,0 +1,136 @@
# retoor <retoor@molodetz.nl>
import pytest
from devplacepy.database import invalidate_admins_cache
from devplacepy.services.devii.errors import ToolInputError
from devplacepy.services.devii.tasks.controller import TaskController
from devplacepy.services.devii.tasks.guards import AutomationDenied
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
from devplacepy.services.devii.tasks.store import TaskStore, claim, due_rows
from devplacepy.utils import generate_uid
def _account(local_db, role):
uid = generate_uid()
local_db["users"].insert(
{
"uid": uid,
"username": f"store-{uid[-10:]}",
"role": role,
"deleted_at": None,
}
)
invalidate_admins_cache()
return uid
def _record(**overrides):
record = {
"uid": generate_uid(),
"prompt": "work",
"enabled": True,
"status": "pending",
"created_at": to_iso(now_utc()),
"next_run_at": to_iso(now_utc()),
"run_count": 0,
"failure_count": 0,
"kind": "interval",
"every_seconds": 900,
"max_runs": 10,
}
record.update(overrides)
return record
def test_member_cannot_persist_a_task(local_db):
store = TaskStore(local_db, "user", _account(local_db, "Member"))
with pytest.raises(AutomationDenied):
store.create(_record())
def test_administrator_can_persist_a_task(local_db):
store = TaskStore(local_db, "user", _account(local_db, "Admin"))
record = _record()
store.create(record)
assert store.get(record["uid"]) is not None
def test_member_cannot_re_enable_a_task(local_db):
admin = _account(local_db, "Admin")
member = _account(local_db, "Member")
admin_store = TaskStore(local_db, "user", admin)
record = _record()
admin_store.create(record)
local_db["devii_tasks"].update(
{"uid": record["uid"], "owner_id": member, "enabled": False}, ["uid"]
)
member_store = TaskStore(local_db, "user", member)
with pytest.raises(AutomationDenied):
member_store.update(record["uid"], {"enabled": True})
member_store.update(record["uid"], {"enabled": False, "status": "disabled"})
def test_local_operator_store_bypasses_the_role_gate(local_db):
store = TaskStore(local_db, "user", "cli", operator=True)
record = _record()
store.create(record)
assert store.get(record["uid"]) is not None
def test_claim_succeeds_once(local_db):
store = TaskStore(local_db, "user", _account(local_db, "Admin"))
record = _record()
store.create(record)
assert claim(local_db, record["uid"]) is True
assert claim(local_db, record["uid"]) is False
def test_claim_refuses_a_disabled_task(local_db):
store = TaskStore(local_db, "user", _account(local_db, "Admin"))
record = _record()
store.create(record)
store.update(record["uid"], {"enabled": False, "status": "disabled"})
assert claim(local_db, record["uid"]) is False
def test_due_rows_skips_future_and_running_tasks(local_db):
admin = _account(local_db, "Admin")
store = TaskStore(local_db, "user", admin)
now = now_utc()
due = _record(next_run_at=to_iso(now.replace(year=now.year - 1)))
future = _record(next_run_at=to_iso(now.replace(year=now.year + 1)))
running = _record(
status="running", next_run_at=to_iso(now.replace(year=now.year - 1))
)
for record in (due, future, running):
store.create(record)
found = {row["uid"] for row in due_rows(local_db, to_iso(now), 500)}
assert due["uid"] in found
assert future["uid"] not in found
assert running["uid"] not in found
def test_controller_caps_active_tasks_per_owner(local_db, monkeypatch):
monkeypatch.setattr(
"devplacepy.services.devii.tasks.controller.max_active_per_owner", lambda: 2
)
store = TaskStore(local_db, "user", _account(local_db, "Admin"))
controller = TaskController(store)
for _ in range(2):
controller.create_task(
{"prompt": "work", "kind": "interval", "every_seconds": 900}
)
with pytest.raises(ToolInputError):
controller.create_task(
{"prompt": "work", "kind": "interval", "every_seconds": 900}
)
def test_controller_refuses_a_member(local_db):
store = TaskStore(local_db, "user", _account(local_db, "Member"))
controller = TaskController(store)
with pytest.raises(ToolInputError):
controller.create_task(
{"prompt": "work", "kind": "interval", "every_seconds": 900}
)
+180 -8
View File
@@ -270,7 +270,7 @@ def test_market_buff_factor_only_applies_to_starter_crops():
def test_market_factor_bounds_across_domain():
floor = economy.MARKET_SATURATION_TIERS[-1][1]
for key in economy.MARKET_TRACKED_CROPS:
for key in economy.MARKET_BUFFED_CROPS + economy.MARKET_PRESSURE_CROPS:
crop = economy.crop_for(key)
for harvests in range(0, 300000, 977):
saturation = economy.market_saturation_factor(economy.supply_days(crop, harvests))
@@ -365,11 +365,13 @@ def test_cosmetic_title_name_only_resolves_titles():
def test_mastery_points_awarded_first_point_at_unlock():
assert economy.mastery_points_awarded(49, 50) == 1
assert economy.mastery_points_awarded(0, 49) == 0
assert economy.mastery_points_awarded(55, 56) == 0
assert economy.mastery_points_awarded(59, 60) == 1
assert economy.mastery_points_awarded(50, 70) == 2
step = economy.MASTERY_PRESTIGE_STEP
unlock = economy.MASTERY_UNLOCK_PRESTIGE
assert economy.mastery_points_awarded(unlock - 1, unlock) == 1
assert economy.mastery_points_awarded(0, unlock - 1) == 0
assert economy.mastery_points_awarded(unlock, unlock + step - 1) == 0
assert economy.mastery_points_awarded(unlock + step - 1, unlock + step) == 1
assert economy.mastery_points_awarded(unlock, unlock + step * 2) == 2
def test_mastery_for_known_and_unknown():
@@ -490,12 +492,182 @@ def test_refactor_carryover_zero_when_unaffordable():
def test_grant_amount_capped_and_bounded():
assert economy.grant_amount(0) == 0
assert economy.grant_amount(-10) == 0
assert economy.grant_amount(100) == 100
assert economy.grant_amount(100) == 0
assert economy.grant_amount(economy.GRANT_MIN_AMOUNT) == economy.GRANT_MIN_AMOUNT
assert economy.grant_amount(10**9) == economy.GRANT_CAP
def test_grant_amount_shares_between_eligible_farms():
assert economy.grant_amount(10_000, 5) == 2_000
assert economy.grant_amount(10**9, 4) == economy.GRANT_CAP
assert economy.grant_amount(1_000, 10) == 0
for eligible in range(1, 50):
share = economy.grant_amount(10**6, eligible)
assert 0 <= share <= economy.GRANT_CAP
assert share * eligible <= 10**6 or share == economy.GRANT_CAP
def test_legacy_carryover_upgrade_registered():
upgrade = economy.legacy_for("carryover")
assert upgrade is not None
assert upgrade.max_level == 5
assert upgrade.max_level == 10
assert "carry-over" in economy.legacy_value_text(upgrade, 2)
assert economy.refactor_carryover_fraction(upgrade.max_level) < 1.0
def test_fertilize_is_never_profitable_across_the_input_domain():
import itertools
for crop in economy.CROPS:
for prestige in range(0, 201, 23):
for yield_level in (0, 5, 10):
for legacy in (0, 5, 10):
for market in (0.40, 0.70, 1.0, 1.15):
for golden, contract, underdog in itertools.product(
(False, True), repeat=3
):
value = economy.realizable_harvest_coins(
crop,
yield_level,
prestige,
legacy,
market,
golden,
contract,
underdog,
)
cost = economy.fertilize_click_cost(
value, crop.grow_seconds, crop.grow_seconds
)
assert cost >= value, (
crop.key,
prestige,
golden,
contract,
underdog,
cost,
value,
)
def test_fertilize_priced_above_the_expected_canary_payout():
for crop in economy.CROPS:
for prestige in (0, 25, 100):
plain = economy.realizable_harvest_coins(crop, 10, prestige, 10, 1.0)
priced = economy.realizable_harvest_coins(
crop, 10, prestige, 10, 1.0, canary=True
)
cost = economy.fertilize_click_cost(
priced, crop.grow_seconds, crop.grow_seconds
)
expected = plain * (
economy.CANARY_DOUBLE_CHANCE * 2
+ (1 - economy.CANARY_DOUBLE_CHANCE - economy.CANARY_FAIL_CHANCE)
) + economy.CANARY_FAIL_CHANCE * min(plain, crop.cost)
assert cost >= expected
def test_staged_fertilize_never_undercuts_the_payout():
for key in ("shell", "api", "kernel", "secfort"):
crop = economy.crop_for(key)
value = economy.realizable_harvest_coins(crop, 10, 50, 10, 1.0, True, True, True)
remaining, total = crop.grow_seconds, 0
while True:
reduce_by = int(remaining * economy.FERTILIZE_FRACTION)
if reduce_by < 1:
break
total += economy.fertilize_click_cost(value, reduce_by, crop.grow_seconds)
remaining -= reduce_by
assert total >= value
def test_every_defense_tier_reduces_the_raider_share():
shares = []
for tier in economy.DEFENSE_TIERS:
share = economy.effective_steal_fraction(
0, tier.steal_fraction_floor, tier.steal_reduction
)
shares.append(round(share, 4))
assert len(set(shares)) == len(economy.DEFENSE_TIERS)
assert shares == sorted(shares, reverse=True)
def test_more_defense_never_increases_the_raider_share():
previous = 1.0
for tier in economy.DEFENSE_TIERS:
for legacy in range(0, 6):
share = economy.effective_steal_fraction(
legacy, tier.steal_fraction_floor, tier.steal_reduction
)
assert 0.0 <= share <= 1.0
top = economy.effective_steal_fraction(
0, tier.steal_fraction_floor, tier.steal_reduction
)
assert top <= previous + 1e-9
previous = top
def test_observability_only_ever_lowers_the_raider_share():
for tier in economy.DEFENSE_TIERS:
for legacy in range(0, 6):
without = economy.effective_steal_fraction(
legacy, tier.steal_fraction_floor, tier.steal_reduction
)
with_suite = economy.effective_steal_fraction(
legacy,
tier.steal_fraction_floor,
tier.steal_reduction,
economy.OBSERVABILITY_STEAL_CAP,
)
assert with_suite <= without + 1e-9
assert with_suite <= economy.OBSERVABILITY_STEAL_CAP + 1e-9
def test_raid_share_plus_owner_remainder_never_exceeds_the_build():
for crop in economy.CROPS:
for prestige in range(0, 101, 17):
for golden in (False, True):
value = economy.realizable_harvest_coins(
crop, 10, prestige, 10, 1.0, golden
)
for tier in economy.DEFENSE_TIERS:
for legacy in range(0, 6):
share = economy.effective_steal_fraction(
legacy, tier.steal_fraction_floor, tier.steal_reduction
)
thief = min(max(1, round(value * share)), int(value * (1.0 - 0.0)))
owner = int(value * (1.0 - share))
assert thief + owner <= value
def test_weekly_contract_stars_are_per_kind_not_per_goal():
for kind in economy.QUEST_KINDS:
for scale in (1.0, 13.5, 40.0):
contract = economy.weekly_contract("user-a", "2026-W30", scale)
if contract["kind"] != kind:
continue
assert contract["reward_stars"] == economy.QUEST_DEFS[kind].contract_stars
assert contract["reward_stars"] <= 5
def test_social_rewards_scale_with_the_earner_and_never_regress():
assert economy.water_reward_coins(0, 0) == economy.WATER_REWARD_COINS
assert economy.daily_reward(1) == economy.DAILY_BASE
previous = 0
for prestige in range(0, 101):
reward = economy.water_reward_coins(prestige, 0)
assert reward >= previous
previous = reward
def test_leaderboard_coin_contribution_is_capped():
base = {"xp": 18050, "prestige": 50, "total_harvests": 5000, "ci_tier": 5}
poor = economy.farm_score({**base, "coins": 0})
rich = economy.farm_score({**base, "coins": 10**9})
assert rich - poor <= economy.SCORE_COIN_CAP
def test_market_saturation_is_per_capita():
crop = economy.crop_for("kernel")
assert economy.supply_days(crop, 288, 1) == economy.supply_days(crop, 2880, 10)
assert economy.supply_days(crop, 288, 4) < economy.supply_days(crop, 288, 1)
+56 -3
View File
@@ -195,7 +195,9 @@ def test_yield_perk_and_prestige_boost_harvest_coins(local_db):
_warp(user)
result = store.harvest(user, 0)
crop = economy.crop_for("shell")
assert result["coins"] == economy.effective_reward_coins(crop, 4, 1)
assert result["coins"] == economy.realizable_harvest_coins(
crop, 4, 1, golden=result["golden"]
)
assert result["coins"] > crop.reward_coins
@@ -599,7 +601,7 @@ def _ripen_past_grace(user, slot=0):
)
def test_steal_transfers_coins_and_clears_plot(local_db):
def test_steal_transfers_a_share_and_leaves_the_build(local_db):
owner = _reset("unit_owner")
thief = _reset("unit_thief", coins=0)
store.plant(owner, 0, "shell")
@@ -608,7 +610,57 @@ def test_steal_transfers_coins_and_clears_plot(local_db):
assert result["coins"] > 0
assert _coins(thief) == result["coins"]
farm = store.get_farm(owner["uid"])
assert (store._plot_at(farm["uid"], 0).get("crop_key") or "") == ""
plot = store._plot_at(farm["uid"], 0)
assert (plot.get("crop_key") or "") == "shell"
assert 0 < float(plot.get("raided_fraction") or 0) <= 1.0
def test_owner_still_harvests_the_unraided_remainder(local_db):
owner = _reset("unit_owner")
thief = _reset("unit_thief", coins=0)
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
before = _coins(owner)
stolen = store.steal(thief, owner, 0)["coins"]
result = store.harvest(owner, 0)
harvested = result["coins"]
assert harvested > 0
assert _coins(owner) == before + harvested
full = economy.realizable_harvest_coins(
economy.crop_for("shell"), golden=result["golden"]
)
assert harvested + stolen <= full
def test_steal_blocked_once_the_build_is_fully_stripped(local_db):
owner = _reset("unit_owner")
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
farm = store.get_farm(owner["uid"])
plot = store._plot_at(farm["uid"], 0)
get_table("game_plots").update(
{"uid": plot["uid"], "raided_fraction": 1.0}, ["uid"]
)
thief = _reset("unit_thief", coins=0)
with pytest.raises(GameError):
store.steal(thief, owner, 0)
def test_steal_capped_per_victim_per_day(local_db):
owner = _reset("unit_owner")
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
for index in range(economy.STEAL_MAX_PER_VICTIM_PER_DAY):
thief = _reset(f"unit_thief_{index}", coins=0)
store.steal(thief, owner, 0)
farm = store.get_farm(owner["uid"])
plot = store._plot_at(farm["uid"], 0)
get_table("game_plots").update(
{"uid": plot["uid"], "raided_fraction": 0.0}, ["uid"]
)
blocked = _reset("unit_thief_last", coins=0)
with pytest.raises(GameError):
store.steal(blocked, owner, 0)
def test_steal_protected_within_grace(local_db):
@@ -631,6 +683,7 @@ def test_steal_cooldown_blocks_second_raid(local_db):
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
store.steal(thief, owner, 0)
store.harvest(owner, 0)
store.plant(owner, 0, "shell")
_ripen_past_grace(owner)
with pytest.raises(GameError):
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+235
View File
@@ -0,0 +1,235 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
import pytest
from pydantic import ValidationError
from devplacepy.config import QUIZ_MAX_OPTIONS, QUIZ_MAX_QUESTIONS
from devplacepy.database import get_table
from devplacepy.models import QuizDocument, QuizImportForm
from devplacepy.services.quiz import store
from devplacepy.services.quiz.store import QuizError
from devplacepy.utils import generate_uid, make_combined_slug
_counter = [0]
DOCUMENT = {
"title": "SQLite fundamentals",
"description": "Questions on WAL and indexing.",
"settings": {"shuffle_questions": True, "reveal_answers": True, "pass_percent": 70},
"questions": [
{
"kind": "single_choice",
"prompt": "Which journal mode allows concurrent readers?",
"points": 2,
"explanation": "WAL keeps readers off the writer's lock.",
"options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}],
},
{
"kind": "free_text",
"prompt": "Why does a partial index help?",
"points": 3,
"expected_answer": "It keeps the live query off a one bucket index.",
"grading_criteria": "Accept any answer mentioning the planner.",
},
],
}
def _user(prefix):
_counter[0] += 1
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": f"{prefix}{_counter[0]}",
"email": f"{prefix}{_counter[0]}@t.dev",
"role": "Member",
"api_key": generate_uid(),
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return get_table("users").find_one(uid=uid)
def _quiz(owner, document):
uid = generate_uid()
get_table("quizzes").insert(
store.born_live(
{
"uid": uid,
"user_uid": owner["uid"],
"slug": make_combined_slug(document.title, uid),
"title": document.title,
"description": document.description,
"status": "draft",
"published_at": "",
"question_count": 0,
"total_points": 0,
"attempt_count": 0,
"stars": 0,
**store.document_settings(document),
}
)
)
store.import_questions(uid, document)
return store.get_quiz(uid)
def test_parse_document_accepts_the_reference_shape():
document = store.parse_document(DOCUMENT)
assert document.title == "SQLite fundamentals"
assert len(document.questions) == 2
def test_parse_document_rejects_junk():
with pytest.raises(QuizError):
store.parse_document({"title": "x"})
def test_the_import_form_parses_a_json_string():
import json
form = QuizImportForm(document=json.dumps(DOCUMENT))
assert form.document.title == "SQLite fundamentals"
def test_the_import_form_rejects_invalid_json():
with pytest.raises(ValidationError):
QuizImportForm(document="{not json")
def test_a_document_needs_at_least_one_question():
with pytest.raises(ValidationError):
QuizDocument(title="Empty quiz", questions=[])
def test_a_document_is_capped_at_the_question_limit():
question = {"kind": "numeric", "prompt": "q", "numeric_value": 1}
with pytest.raises(ValidationError):
QuizDocument(title="Too big", questions=[question] * (QUIZ_MAX_QUESTIONS + 1))
def test_a_question_is_capped_at_the_option_limit():
options = [{"label": f"L{index}"} for index in range(QUIZ_MAX_OPTIONS + 1)]
options[0]["is_correct"] = True
with pytest.raises(ValidationError):
QuizDocument(
title="Too many options",
questions=[{"kind": "single_choice", "prompt": "q", "options": options}],
)
def test_a_single_choice_needs_exactly_one_correct_option():
with pytest.raises(ValidationError):
QuizDocument(
title="Bad choice",
questions=[
{
"kind": "single_choice",
"prompt": "q",
"options": [{"label": "a"}, {"label": "b"}],
}
],
)
def test_a_free_text_needs_a_reference_or_criteria():
with pytest.raises(ValidationError):
QuizDocument(
title="Bad free text",
questions=[{"kind": "free_text", "prompt": "explain"}],
)
def test_a_matching_needs_a_right_hand_value_on_every_pair():
with pytest.raises(ValidationError):
QuizDocument(
title="Bad matching",
questions=[
{
"kind": "matching",
"prompt": "q",
"options": [{"label": "a", "match_value": "A"}, {"label": "b"}],
}
],
)
def test_document_settings_map_onto_the_quiz_columns():
settings = store.document_settings(store.parse_document(DOCUMENT))
assert settings["shuffle_questions"] == 1
assert settings["reveal_answers"] == 1
assert settings["pass_percent"] == 70
def test_importing_creates_every_question_in_order(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
questions = store.list_questions(quiz["uid"])
assert [q["kind"] for q in questions] == ["single_choice", "free_text"]
assert [q["position"] for q in questions] == [0, 1]
def test_importing_recomputes_the_totals(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
assert quiz["question_count"] == 2
assert quiz["total_points"] == 5
def test_importing_stores_the_kind_specific_fields(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
free_text = store.list_questions(quiz["uid"])[1]
assert free_text["expected_answer"].startswith("It keeps")
assert free_text["grading_criteria"].startswith("Accept")
def test_the_owner_export_round_trips_through_import(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
exported = store.export_document(quiz["uid"], True)
reimported = _quiz(owner, store.parse_document(exported))
assert reimported["question_count"] == quiz["question_count"]
assert reimported["total_points"] == quiz["total_points"]
assert store.export_document(reimported["uid"], True) == exported
def test_the_owner_export_carries_the_answer_key(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
exported = store.export_document(quiz["uid"], True)
assert any(option.get("is_correct") for option in exported["questions"][0]["options"])
assert exported["questions"][1]["expected_answer"]
def test_the_public_export_omits_the_answer_key(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
exported = store.export_document(quiz["uid"], False)
assert all("is_correct" not in option for option in exported["questions"][0]["options"])
assert "expected_answer" not in exported["questions"][1]
assert "correct_boolean" not in exported["questions"][0]
def test_the_public_export_keeps_the_prompts_and_labels(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
exported = store.export_document(quiz["uid"], False)
assert exported["questions"][0]["prompt"].startswith("Which journal mode")
assert [option["label"] for option in exported["questions"][0]["options"]] == ["DELETE", "WAL"]
def test_the_export_carries_the_settings(local_db):
owner = _user("qd")
quiz = _quiz(owner, store.parse_document(DOCUMENT))
exported = store.export_document(quiz["uid"], False)
assert exported["settings"]["pass_percent"] == 70
assert exported["settings"]["reveal_answers"] is True
def test_exporting_an_unknown_quiz_raises(local_db):
with pytest.raises(QuizError):
store.export_document("does-not-exist", True)
+149
View File
@@ -0,0 +1,149 @@
# retoor <retoor@molodetz.nl>
import json
from devplacepy.config import QUIZ_FEEDBACK_MAX_CHARS
from devplacepy.services.quiz import grading, scoring
QUESTION = {
"uid": "q1",
"prompt": "Why does a partial index help here?",
"expected_answer": "It keeps the live IS NULL query off a one bucket index.",
"grading_criteria": "Accept any answer mentioning the planner.",
"points": 3,
}
def test_parse_verdict_reads_a_plain_object():
parsed = grading.parse_verdict('{"correct": true, "score": 0.8}')
assert parsed == {"correct": True, "score": 0.8}
def test_parse_verdict_tolerates_a_code_fence():
parsed = grading.parse_verdict('```json\n{"score": 0.5}\n```')
assert parsed == {"score": 0.5}
def test_parse_verdict_tolerates_surrounding_prose():
parsed = grading.parse_verdict('Here you go: {"score": 1.0} hope that helps')
assert parsed == {"score": 1.0}
def test_parse_verdict_returns_none_for_junk():
assert grading.parse_verdict("not json at all") is None
assert grading.parse_verdict("") is None
def test_parse_verdict_returns_none_for_a_json_list():
assert grading.parse_verdict("[1, 2, 3]") is None
def test_build_result_clamps_a_high_score():
result = grading.build_result({"score": 4.0, "correct": True})
assert result.score == 1.0
def test_build_result_clamps_a_negative_score():
result = grading.build_result({"score": -2.0, "correct": False})
assert result.score == 0.0
def test_build_result_derives_correct_from_the_clamped_score():
result = grading.build_result({"correct": True, "score": 0.0})
assert result.is_correct is False
def test_build_result_marks_a_high_score_correct_despite_the_model():
result = grading.build_result({"correct": False, "score": 0.9})
assert result.is_correct is True
def test_build_result_falls_back_to_the_boolean_without_a_score():
assert grading.build_result({"correct": True}).score == 1.0
assert grading.build_result({"correct": False}).score == 0.0
def test_build_result_handles_an_unparsable_score():
assert grading.build_result({"correct": True, "score": "lots"}).score == 1.0
def test_build_result_clamps_the_confidence():
assert grading.build_result({"score": 1.0, "confidence": 9.0}).confidence == 1.0
assert grading.build_result({"score": 1.0, "confidence": -9.0}).confidence == 0.0
def test_build_result_truncates_and_strips_feedback():
parsed = {"score": 1.0, "feedback": "<b>nice</b> " + "x" * (QUIZ_FEEDBACK_MAX_CHARS + 50)}
result = grading.build_result(parsed)
assert "<b>" not in result.feedback
assert len(result.feedback) <= QUIZ_FEEDBACK_MAX_CHARS
def test_build_result_always_has_feedback():
assert grading.build_result({"score": 1.0}).feedback
def test_build_result_is_stamped_as_ai():
assert grading.build_result({"score": 1.0}).graded_by == "ai"
def test_build_prompt_carries_the_grading_context():
payload = json.loads(grading.build_prompt(QUESTION, "because the planner"))
assert payload["reference_answer"] == QUESTION["expected_answer"]
assert payload["grading_criteria"] == QUESTION["grading_criteria"]
assert payload["learner_answer"] == "because the planner"
def test_system_prompt_treats_the_answer_as_data():
assert "DATA" in grading.SYSTEM_PROMPT
assert "JSON" in grading.SYSTEM_PROMPT
def test_grade_free_text_falls_back_without_an_api_key():
result = grading.grade_free_text("", QUESTION, "the planner picks a bad index")
assert result.graded_by == "fallback"
assert 0.0 <= result.score <= 1.0
def test_grade_free_text_falls_back_when_the_gateway_raises(monkeypatch):
def boom(*args, **kwargs):
raise RuntimeError("gateway down")
monkeypatch.setattr(grading, "gateway_complete", boom)
result = grading.grade_free_text("key", QUESTION, "the planner picks a bad index")
assert result.graded_by == "fallback"
assert "unavailable" in result.feedback
def test_grade_free_text_falls_back_on_an_unreadable_reply(monkeypatch):
monkeypatch.setattr(grading, "gateway_complete", lambda *a, **k: ("not json", None))
result = grading.grade_free_text("key", QUESTION, "the planner picks a bad index")
assert result.graded_by == "fallback"
def test_grade_free_text_uses_a_valid_verdict(monkeypatch):
reply = json.dumps({"correct": True, "score": 0.75, "feedback": "Close.", "confidence": 0.9})
monkeypatch.setattr(grading, "gateway_complete", lambda *a, **k: (reply, None))
result = grading.grade_free_text("key", QUESTION, "the planner")
assert result.graded_by == "ai"
assert result.score == 0.75
assert result.is_correct is True
assert result.feedback == "Close."
def test_grade_free_text_passes_the_answering_key_through(monkeypatch):
seen = {}
def capture(api_key, system, text, timeout):
seen["api_key"] = api_key
return json.dumps({"score": 1.0}), None
monkeypatch.setattr(grading, "gateway_complete", capture)
grading.grade_free_text("member-key", QUESTION, "answer")
assert seen["api_key"] == "member-key"
def test_the_fallback_score_is_the_deterministic_overlap():
expected = scoring.token_overlap_score(QUESTION["expected_answer"], "one bucket index")
result = grading.grade_free_text("", QUESTION, "one bucket index")
assert result.score == expected
+387
View File
@@ -0,0 +1,387 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.services.quiz import store
from devplacepy.utils import generate_uid, make_combined_slug
_counter = [0]
def _user(prefix):
_counter[0] += 1
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": f"{prefix}{_counter[0]}",
"email": f"{prefix}{_counter[0]}@t.dev",
"role": "Member",
"api_key": generate_uid(),
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return get_table("users").find_one(uid=uid)
def _published(owner, points=10):
uid = generate_uid()
get_table("quizzes").insert(
store.born_live(
{
"uid": uid,
"user_uid": owner["uid"],
"slug": make_combined_slug("Board", uid),
"title": "Board",
"description": "",
"status": "published",
"published_at": datetime.now(timezone.utc).isoformat(),
"shuffle_questions": 0,
"shuffle_options": 0,
"reveal_answers": 0,
"allow_review": 1,
"time_limit_seconds": 0,
"pass_percent": 0,
"question_count": 1,
"total_points": points,
"attempt_count": 0,
"stars": 0,
}
)
)
return store.get_quiz(uid)
def _completed(player, quiz, points, percent):
uid = generate_uid()
get_table("quiz_attempts").insert(
store.born_live(
{
"uid": uid,
"quiz_uid": quiz["uid"],
"user_uid": player["uid"],
"status": "completed",
"question_order": "[]",
"started_at": datetime.now(timezone.utc).isoformat(),
"expires_at": "",
"completed_at": datetime.now(timezone.utc).isoformat(),
"answered_count": 1,
"score_points": float(points),
"max_points": int(quiz["total_points"]),
"score_percent": float(percent),
"passed": 0,
}
)
)
store.clear_cache()
return uid
def _in_progress(player, quiz):
uid = generate_uid()
get_table("quiz_attempts").insert(
store.born_live(
{
"uid": uid,
"quiz_uid": quiz["uid"],
"user_uid": player["uid"],
"status": "in_progress",
"question_order": "[]",
"started_at": datetime.now(timezone.utc).isoformat(),
"expires_at": "",
"completed_at": "",
"answered_count": 0,
"score_points": 0.0,
"max_points": int(quiz["total_points"]),
"score_percent": 0.0,
"passed": 0,
}
)
)
store.clear_cache()
return uid
def _entry_for(player):
for entry in store.scoreboard(100):
if entry["user"]["uid"] == player["uid"]:
return entry
return None
def test_a_completed_attempt_puts_a_member_on_the_board(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 8, 80.0)
entry = _entry_for(player)
assert entry["total_points"] == 8.0
assert entry["quizzes_completed"] == 1
def test_only_the_best_attempt_per_quiz_counts(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 4, 40.0)
_completed(player, quiz, 9, 90.0)
_completed(player, quiz, 2, 20.0)
entry = _entry_for(player)
assert entry["total_points"] == 9.0
assert entry["quizzes_completed"] == 1
def test_replaying_a_quiz_can_never_multiply_a_score(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 7, 70.0)
before = _entry_for(player)["total_points"]
for _ in range(5):
_completed(player, quiz, 7, 70.0)
assert _entry_for(player)["total_points"] == before
def test_a_worse_attempt_never_lowers_the_contribution(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 9, 90.0)
_completed(player, quiz, 1, 10.0)
assert _entry_for(player)["total_points"] == 9.0
def test_a_better_attempt_raises_by_exactly_the_difference(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 4, 40.0)
_completed(player, quiz, 10, 100.0)
assert _entry_for(player)["total_points"] == 10.0
def test_two_quizzes_sum_their_best_attempts(local_db):
owner = _user("sb")
player = _user("sb")
first = _published(owner)
second = _published(owner)
_completed(player, first, 6, 60.0)
_completed(player, second, 3, 30.0)
entry = _entry_for(player)
assert entry["total_points"] == 9.0
assert entry["quizzes_completed"] == 2
def test_an_author_scores_on_their_own_quiz(local_db):
owner = _user("sb")
quiz = _published(owner)
_completed(owner, quiz, 10, 100.0)
entry = _entry_for(owner)
assert entry["total_points"] == 10.0
assert entry["quizzes_completed"] == 1
assert entry["perfect_count"] == 1
def test_an_in_progress_attempt_is_not_counted(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_in_progress(player, quiz)
assert _entry_for(player) is None
def test_a_draft_quiz_is_not_counted(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
get_table("quizzes").update({"uid": quiz["uid"], "status": "draft"}, ["uid"])
_completed(player, quiz, 10, 100.0)
assert _entry_for(player) is None
def test_a_deleted_attempt_is_not_counted(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
attempt_uid = _completed(player, quiz, 10, 100.0)
get_table("quiz_attempts").update(
{"uid": attempt_uid, "deleted_at": datetime.now(timezone.utc).isoformat()}, ["uid"]
)
store.clear_cache()
assert _entry_for(player) is None
def test_ranks_are_a_gapless_total_order(local_db):
owner = _user("sb")
quiz = _published(owner)
for score in (3, 9, 6):
_completed(_user("sb"), quiz, score, score * 10.0)
ranks = [entry["rank"] for entry in store.scoreboard(100)]
assert ranks == list(range(1, len(ranks) + 1))
def test_equal_totals_resolve_deterministically(local_db):
owner = _user("sb")
quiz = _published(owner)
for _ in range(3):
_completed(_user("sb"), quiz, 5, 50.0)
first = [entry["user"]["uid"] for entry in store.scoreboard(100)]
store.clear_cache()
assert [entry["user"]["uid"] for entry in store.scoreboard(100)] == first
def test_the_limit_bounds_the_board(local_db):
owner = _user("sb")
quiz = _published(owner)
for score in range(1, 6):
_completed(_user("sb"), quiz, score, score * 10.0)
assert len(store.scoreboard(3)) == 3
def test_perfect_attempts_are_counted(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 10, 100.0)
assert _entry_for(player)["perfect_count"] == 1
def test_standing_for_finds_a_member_outside_the_top(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 2, 20.0)
standing = store.standing_for(player["uid"])
assert standing["user"]["uid"] == player["uid"]
assert standing["rank"] >= 1
def test_standing_for_is_none_without_attempts(local_db):
assert store.standing_for(_user("sb")["uid"]) is None
def test_attempt_states_reports_done_with_the_best_percentage(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 4, 40.0)
_completed(player, quiz, 9, 90.0)
states = store.attempt_states_for(player["uid"], [quiz["uid"]])
assert states[quiz["uid"]]["state"] == "done"
assert states[quiz["uid"]]["best_percent"] == 90.0
def test_attempt_states_reports_in_progress(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_in_progress(player, quiz)
states = store.attempt_states_for(player["uid"], [quiz["uid"]])
assert states[quiz["uid"]]["state"] == "in_progress"
assert states[quiz["uid"]]["attempt_uid"]
def test_attempt_states_is_empty_for_a_guest(local_db):
owner = _user("sb")
quiz = _published(owner)
assert store.attempt_states_for("", [quiz["uid"]]) == {}
def test_attempt_states_is_empty_without_quiz_uids(local_db):
assert store.attempt_states_for(_user("sb")["uid"], []) == {}
def test_completed_quiz_uids_lists_only_finished_quizzes(local_db):
owner = _user("sb")
player = _user("sb")
done = _published(owner)
open_quiz = _published(owner)
_completed(player, done, 5, 50.0)
_in_progress(player, open_quiz)
completed = store.completed_quiz_uids(player["uid"])
assert done["uid"] in completed
assert open_quiz["uid"] not in completed
def test_progress_for_summarises_the_viewer(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 10, 100.0)
progress = store.progress_for(player["uid"])
assert progress["completed"] == 1
assert progress["avg_percent"] == 100.0
assert progress["perfect_count"] == 1
def test_the_quiz_leaderboard_ranks_by_percentage(local_db):
owner = _user("sb")
quiz = _published(owner)
_completed(_user("sb"), quiz, 3, 30.0)
_completed(_user("sb"), quiz, 9, 90.0)
entries = store.quiz_leaderboard(quiz["uid"])
assert entries[0]["score_percent"] == 90.0
assert entries[0]["rank"] == 1
def test_the_quiz_leaderboard_ignores_unfinished_attempts(local_db):
owner = _user("sb")
quiz = _published(owner)
_in_progress(_user("sb"), quiz)
assert store.quiz_leaderboard(quiz["uid"]) == []
def test_progress_agrees_with_the_scoreboard_for_an_author_only_completion(local_db):
author = _user("sb")
quiz = _published(author)
_completed(author, quiz, 10, 100.0)
progress = store.progress_for(author["uid"])
entry = _entry_for(author)
assert progress["completed"] == entry["quizzes_completed"] == 1
assert progress["avg_percent"] == entry["avg_percent"] == 100.0
assert progress["total_points"] == entry["total_points"] == 10.0
assert progress["perfect_count"] == entry["perfect_count"] == 1
assert progress["rank"] == entry["rank"]
def test_progress_counts_a_ranked_completion(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
_completed(player, quiz, 8, 80.0)
progress = store.progress_for(player["uid"])
entry = _entry_for(player)
assert progress["completed"] == entry["quizzes_completed"] == 1
assert progress["avg_percent"] == entry["avg_percent"] == 80.0
assert progress["total_points"] == entry["total_points"] == 8.0
def test_completing_your_own_quiz_clears_it_from_todo(local_db):
author = _user("sb")
before = store.progress_for(author["uid"])["todo"]
quiz = _published(author)
assert store.progress_for(author["uid"])["todo"] == before + 1
_completed(author, quiz, 10, 100.0)
assert store.progress_for(author["uid"])["todo"] == before
def test_progress_todo_counts_every_published_quiz(local_db):
author = _user("sb")
other = _user("sb")
before = store.progress_for(author["uid"])["todo"]
_published(author)
_published(other)
_published(other)
assert store.progress_for(author["uid"])["todo"] == before + 3
def test_completing_a_quiz_moves_it_from_todo_to_completed(local_db):
owner = _user("sb")
player = _user("sb")
quiz = _published(owner)
before = store.progress_for(player["uid"])
_completed(player, quiz, 5, 50.0)
after = store.progress_for(player["uid"])
assert after["completed"] == before["completed"] + 1
assert after["todo"] == before["todo"] - 1
+278
View File
@@ -0,0 +1,278 @@
# retoor <retoor@molodetz.nl>
import json
from datetime import datetime, timedelta, timezone
import pytest
from devplacepy.services.quiz import scoring
OPTIONS = [
{"uid": "o0", "position": 0, "label": "L0", "match_value": "M0", "is_correct": 1},
{"uid": "o1", "position": 1, "label": "L1", "match_value": "M1", "is_correct": 1},
{"uid": "o2", "position": 2, "label": "L2", "match_value": "M2", "is_correct": 0},
{"uid": "o3", "position": 3, "label": "L3", "match_value": "M3", "is_correct": 0},
]
NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=timezone.utc)
def question(kind, **extra):
base = {
"uid": "q1",
"kind": kind,
"points": 3,
"correct_boolean": 0,
"numeric_value": 0.0,
"numeric_tolerance": 0.0,
"case_sensitive": 0,
}
base.update(extra)
return base
def test_question_kinds_are_the_eight_documented_keys():
assert scoring.KIND_KEYS == (
"single_choice",
"multiple_choice",
"true_false",
"free_text",
"fill_blank",
"numeric",
"ordering",
"matching",
)
def test_only_free_text_is_ai_graded():
ai = [kind.key for kind in scoring.QUESTION_KINDS if kind.graded_by == "ai"]
assert ai == ["free_text"]
def test_awarded_points_is_monotonic_and_bounded():
for points in (1, 7, 100):
previous = -1.0
for step in range(0, 201):
awarded = scoring.awarded_points(points, step / 200.0)
assert 0.0 <= awarded <= points
assert awarded >= previous
previous = awarded
def test_awarded_points_clamps_outside_the_unit_interval():
assert scoring.awarded_points(5, 4.0) == 5
assert scoring.awarded_points(5, -4.0) == 0.0
def test_score_percent_stays_within_bounds():
for max_points in (0, 1, 14, 199):
for score_points in (0, 1, 14, 400):
assert 0.0 <= scoring.score_percent(score_points, max_points) <= 100.0
def test_score_percent_is_zero_without_max_points():
assert scoring.score_percent(10, 0) == 0.0
def test_score_percent_is_exactly_full_at_max():
assert scoring.score_percent(14, 14) == 100.0
def test_passed_needs_a_pass_mark():
assert scoring.passed(100.0, 0) is False
assert scoring.passed(70.0, 70) is True
assert scoring.passed(69.9, 70) is False
def test_single_choice_grades_the_correct_option():
result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["o0"]})
assert result.score == 1.0 and result.is_correct and result.graded_by == "auto"
def test_single_choice_rejects_more_than_one_pick():
result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["o0", "o1"]})
assert result.score == 0.0
def test_single_choice_rejects_an_unknown_option():
result = scoring.grade_answer(question("single_choice"), OPTIONS, {"option_uids": ["nope"]})
assert result.score == 0.0
def test_multiple_choice_gives_partial_credit():
result = scoring.grade_answer(question("multiple_choice"), OPTIONS, {"option_uids": ["o0"]})
assert result.score == 0.5 and not result.is_correct
def test_multiple_choice_penalises_wrong_picks_but_never_below_zero():
result = scoring.grade_answer(
question("multiple_choice"), OPTIONS, {"option_uids": ["o2", "o3"]}
)
assert result.score == 0.0
def test_multiple_choice_is_correct_only_on_the_exact_set():
result = scoring.grade_answer(
question("multiple_choice"), OPTIONS, {"option_uids": ["o0", "o1"]}
)
assert result.score == 1.0 and result.is_correct
def test_multiple_choice_ignores_duplicate_picks():
result = scoring.grade_answer(
question("multiple_choice"), OPTIONS, {"option_uids": ["o0", "o0", "o1"]}
)
assert result.score == 1.0
def test_true_false_matches_the_stored_boolean():
prompt = question("true_false", correct_boolean=1)
assert scoring.grade_answer(prompt, [], {"answer_text": "true"}).score == 1.0
assert scoring.grade_answer(prompt, [], {"answer_text": "false"}).score == 0.0
def test_true_false_without_an_answer_scores_zero():
assert scoring.grade_answer(question("true_false"), [], {"answer_text": ""}).score == 0.0
def test_free_text_raises_for_the_ai_grader():
with pytest.raises(scoring.NeedsAiGrading):
scoring.grade_answer(question("free_text"), [], {"answer_text": "anything"})
def test_numeric_accepts_within_tolerance():
prompt = question("numeric", numeric_value=10.0, numeric_tolerance=0.5)
assert scoring.grade_answer(prompt, [], {"answer_text": "10.4"}).score == 1.0
assert scoring.grade_answer(prompt, [], {"answer_text": "10.6"}).score == 0.0
def test_numeric_accepts_a_comma_decimal_separator():
prompt = question("numeric", numeric_value=1.5, numeric_tolerance=0.0)
assert scoring.grade_answer(prompt, [], {"answer_text": "1,5"}).score == 1.0
def test_numeric_rejects_a_non_number():
prompt = question("numeric", numeric_value=1.0)
assert scoring.grade_answer(prompt, [], {"answer_text": "ten"}).score == 0.0
def test_fill_blank_scores_per_blank():
prompt = question("fill_blank")
payload = json.dumps(["M0", "wrong", "M2", "M3"])
result = scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload})
assert result.score == 0.75
def test_fill_blank_normalizes_whitespace_and_case_by_default():
prompt = question("fill_blank")
payload = json.dumps([" m0 ", "M1", "M2", "M3"])
assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 1.0
def test_fill_blank_honours_case_sensitivity():
prompt = question("fill_blank", case_sensitive=1)
payload = json.dumps(["m0", "M1", "M2", "M3"])
assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 0.75
def test_ordering_scores_the_longest_correct_prefix():
prompt = question("ordering")
order = ["o0", "o1", "o3", "o2"]
assert scoring.grade_answer(prompt, OPTIONS, {"option_uids": order}).score == 0.5
def test_ordering_is_correct_on_the_exact_sequence():
prompt = question("ordering")
order = ["o0", "o1", "o2", "o3"]
result = scoring.grade_answer(prompt, OPTIONS, {"option_uids": order})
assert result.score == 1.0 and result.is_correct
def test_matching_scores_per_pair():
prompt = question("matching")
payload = json.dumps({"o0": "M0", "o1": "M1", "o2": "wrong", "o3": "wrong"})
assert scoring.grade_answer(prompt, OPTIONS, {"answer_text": payload}).score == 0.5
def test_every_kind_survives_a_malformed_submission():
malformed = [{}, {"answer_text": None, "option_uids": None}, {"answer_text": "junk"}]
for kind in scoring.KIND_KEYS:
if kind == "free_text":
continue
for submission in malformed:
result = scoring.grade_answer(question(kind), OPTIONS, submission)
assert 0.0 <= result.score <= 1.0
def test_an_unknown_kind_scores_zero_instead_of_raising():
assert scoring.grade_answer(question("telepathy"), OPTIONS, {}).score == 0.0
def test_question_order_is_a_stable_permutation():
uids = [f"u{index}" for index in range(9)]
order = scoring.question_order(uids, True, "attempt-1")
assert sorted(order) == sorted(uids)
assert order == scoring.question_order(uids, True, "attempt-1")
def test_question_order_differs_per_seed():
uids = [f"u{index}" for index in range(12)]
orders = {tuple(scoring.question_order(uids, True, f"a{index}")) for index in range(40)}
assert len(orders) > 1
def test_question_order_is_untouched_without_shuffle():
uids = ["a", "b", "c"]
assert scoring.question_order(uids, False, "seed") == uids
def test_option_order_is_a_stable_permutation():
uids = ["o0", "o1", "o2", "o3"]
order = scoring.option_order(uids, True, "answer-1")
assert sorted(order) == sorted(uids)
assert order == scoring.option_order(uids, True, "answer-1")
def test_token_overlap_scores_a_perfect_match():
assert scoring.token_overlap_score("write ahead logging", "write ahead logging") == 1.0
def test_token_overlap_scores_an_empty_answer_zero():
assert scoring.token_overlap_score("write ahead logging", "") == 0.0
def test_token_overlap_stays_within_bounds():
score = scoring.token_overlap_score("alpha beta gamma", "alpha delta")
assert 0.0 < score < 1.0
def test_attempt_expires_at_is_empty_without_a_limit():
assert scoring.attempt_expires_at(NOW.isoformat(), 0) == ""
def test_attempt_expires_at_adds_the_limit():
expires = scoring.attempt_expires_at(NOW.isoformat(), 900)
assert expires == (NOW + timedelta(seconds=900)).isoformat()
def test_is_expired_reads_the_stored_deadline():
expires = scoring.attempt_expires_at(NOW.isoformat(), 60)
assert scoring.is_expired(expires, NOW) is False
assert scoring.is_expired(expires, NOW + timedelta(seconds=61)) is True
def test_is_expired_is_false_without_a_deadline():
assert scoring.is_expired("", NOW) is False
def test_remaining_seconds_never_goes_negative():
expires = scoring.attempt_expires_at(NOW.isoformat(), 60)
assert scoring.remaining_seconds(expires, NOW) == 60
assert scoring.remaining_seconds(expires, NOW + timedelta(seconds=600)) == 0
def test_fallback_result_is_stamped_and_explains_itself():
result = scoring.fallback_result("write ahead logging", "write ahead logging", "gateway down")
assert result.graded_by == "fallback"
assert result.confidence == 0.0
assert "unavailable" in result.feedback
+531
View File
@@ -0,0 +1,531 @@
# retoor <retoor@molodetz.nl>
import json
from datetime import datetime, timedelta, timezone
import pytest
from devplacepy.database import get_table
from devplacepy.services.quiz import store
from devplacepy.services.quiz.store import QuizError
from devplacepy.utils import generate_uid, make_combined_slug
from tests.conftest import run_async
_counter = [0]
def _user(prefix):
_counter[0] += 1
uid = generate_uid()
get_table("users").insert(
{
"uid": uid,
"username": f"{prefix}{_counter[0]}",
"email": f"{prefix}{_counter[0]}@t.dev",
"role": "Member",
"api_key": generate_uid(),
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return get_table("users").find_one(uid=uid)
def _quiz(owner, **overrides):
uid = generate_uid()
fields = {
"uid": uid,
"user_uid": owner["uid"],
"slug": make_combined_slug("Quiz", uid),
"title": "Quiz",
"description": "d",
"status": "draft",
"published_at": "",
"shuffle_questions": 0,
"shuffle_options": 0,
"reveal_answers": 1,
"allow_review": 1,
"time_limit_seconds": 0,
"pass_percent": 0,
"question_count": 0,
"total_points": 0,
"attempt_count": 0,
"stars": 0,
}
fields.update(overrides)
get_table("quizzes").insert(store.born_live(fields))
return store.get_quiz(uid)
def _numeric(quiz_uid, value=1.0, points=1):
return store.add_question(
quiz_uid,
{
"kind": "numeric",
"prompt": f"Value {value}",
"points": points,
"numeric_value": value,
"numeric_tolerance": 0.0,
"options": [],
},
)
def _choice(quiz_uid, points=2):
return store.add_question(
quiz_uid,
{
"kind": "single_choice",
"prompt": "Pick one",
"points": points,
"options": [
{"label": "wrong", "match_value": "", "is_correct": False},
{"label": "right", "match_value": "", "is_correct": True},
],
},
)
def test_recompute_quiz_totals_reflects_the_live_questions(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_numeric(quiz["uid"], points=3)
_choice(quiz["uid"], points=2)
refreshed = store.get_quiz(quiz["uid"])
assert refreshed["question_count"] == 2
assert refreshed["total_points"] == 5
def test_deleting_a_question_recomputes_and_renumbers(local_db):
owner = _user("qs")
quiz = _quiz(owner)
first = _numeric(quiz["uid"], points=3)
_numeric(quiz["uid"], value=2.0, points=4)
store.delete_question(quiz["uid"], first["uid"], owner["uid"])
refreshed = store.get_quiz(quiz["uid"])
assert refreshed["question_count"] == 1
assert refreshed["total_points"] == 4
assert store.list_questions(quiz["uid"])[0]["position"] == 0
def test_reorder_requires_every_question_exactly_once(local_db):
owner = _user("qs")
quiz = _quiz(owner)
first = _numeric(quiz["uid"])
_numeric(quiz["uid"], value=2.0)
with pytest.raises(QuizError):
store.reorder_questions(quiz["uid"], [first["uid"]])
def test_reorder_applies_the_new_order(local_db):
owner = _user("qs")
quiz = _quiz(owner)
first = _numeric(quiz["uid"])
second = _numeric(quiz["uid"], value=2.0)
store.reorder_questions(quiz["uid"], [second["uid"], first["uid"]])
assert [q["uid"] for q in store.list_questions(quiz["uid"])] == [second["uid"], first["uid"]]
def test_validation_errors_flags_an_empty_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
assert store.validation_errors(quiz["uid"])
def test_validation_errors_is_empty_for_a_complete_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
assert store.validation_errors(quiz["uid"]) == []
def test_validation_errors_flags_a_free_text_without_criteria(local_db):
owner = _user("qs")
quiz = _quiz(owner)
store.add_question(
quiz["uid"],
{"kind": "free_text", "prompt": "Explain", "points": 1, "options": []},
)
assert any("reference answer" in problem for problem in store.validation_errors(quiz["uid"]))
def test_validation_errors_flags_a_matching_with_one_pair(local_db):
owner = _user("qs")
quiz = _quiz(owner)
store.add_question(
quiz["uid"],
{
"kind": "matching",
"prompt": "Match",
"points": 1,
"options": [{"label": "a", "match_value": "b", "is_correct": False}],
},
)
assert any("two pairs" in problem for problem in store.validation_errors(quiz["uid"]))
def test_publish_refuses_an_incomplete_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
with pytest.raises(QuizError):
store.publish_quiz(quiz["uid"])
assert store.get_quiz(quiz["uid"])["status"] == "draft"
def test_publish_sets_the_status_once(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
published = store.publish_quiz(quiz["uid"])
assert published["status"] == "published"
assert published["published_at"]
assert published["publish_won"] is True
def test_publishing_twice_does_not_win_twice(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
again = store.publish_quiz(quiz["uid"])
assert again.get("publish_won") is not True
def test_guard_editable_refuses_a_published_quiz(local_db):
owner = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
with pytest.raises(QuizError):
store.guard_editable(quiz["uid"])
def test_every_mutating_entrypoint_is_frozen_after_publish(local_db):
owner = _user("qs")
quiz = _quiz(owner)
question = _choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
with pytest.raises(QuizError):
store.add_question(quiz["uid"], {"kind": "numeric", "prompt": "x", "options": []})
with pytest.raises(QuizError):
store.edit_question(quiz["uid"], question["uid"], {"kind": "numeric", "prompt": "x", "options": []})
with pytest.raises(QuizError):
store.delete_question(quiz["uid"], question["uid"], owner["uid"])
with pytest.raises(QuizError):
store.reorder_questions(quiz["uid"], [question["uid"]])
with pytest.raises(QuizError):
store.edit_quiz(quiz["uid"], {"title": "new"})
def test_a_published_quiz_keeps_its_question_bytes(local_db):
owner = _user("qs")
quiz = _quiz(owner)
question = _choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
with pytest.raises(QuizError):
store.edit_question(
quiz["uid"], question["uid"], {"kind": "numeric", "prompt": "changed", "options": []}
)
assert store.list_questions(quiz["uid"])[0]["prompt"] == "Pick one"
def test_can_view_quiz_hides_a_draft_from_everyone_else(local_db):
owner = _user("qs")
other = _user("qs")
quiz = _quiz(owner)
assert store.can_view_quiz(quiz, owner) is True
assert store.can_view_quiz(quiz, other) is False
assert store.can_view_quiz(quiz, None) is False
def test_can_view_quiz_allows_everyone_once_published(local_db):
owner = _user("qs")
other = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
assert store.can_view_quiz(store.get_quiz(quiz["uid"]), other) is True
assert store.can_view_quiz(store.get_quiz(quiz["uid"]), None) is True
def test_starting_twice_returns_the_same_attempt(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
first = store.start_attempt(player, quiz)
second = store.start_attempt(player, quiz)
assert first["uid"] == second["uid"]
live = get_table("quiz_attempts").count(
user_uid=player["uid"], quiz_uid=quiz["uid"], status="in_progress", deleted_at=None
)
assert live == 1
def test_starting_materializes_a_blank_answer_per_question(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
_numeric(quiz["uid"])
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
answers = store.answers_for(attempt["uid"])
assert len(answers) == 2
assert all(not answer["answered_at"] for answer in answers)
def test_starting_snapshots_the_max_points(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"], points=5)
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
assert attempt["max_points"] == 5
def test_answering_credits_the_attempt(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
question = _numeric(quiz["uid"], value=7.0, points=4)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
answer, updated, result = run_async(
store.answer(
player, quiz, attempt, question["uid"], {"answer_text": "7", "option_uids": []}
)
)
assert answer["is_correct"] == 1
assert float(answer["awarded_points"]) == 4.0
assert float(updated["score_points"]) == 4.0
assert int(updated["answered_count"]) == 1
assert result.graded_by == "auto"
def test_a_second_answer_is_refused_and_credits_nothing(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
question = _numeric(quiz["uid"], value=7.0, points=4)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
run_async(
store.answer(
player, quiz, attempt, question["uid"], {"answer_text": "7", "option_uids": []}
)
)
with pytest.raises(QuizError):
run_async(
store.answer(
player,
quiz,
store.get_attempt(attempt["uid"]),
question["uid"],
{"answer_text": "7", "option_uids": []},
)
)
refreshed = store.get_attempt(attempt["uid"])
assert float(refreshed["score_points"]) == 4.0
assert int(refreshed["answered_count"]) == 1
def test_finishing_recomputes_the_score_from_the_answers(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, pass_percent=50)
first = _numeric(quiz["uid"], value=1.0, points=2)
_numeric(quiz["uid"], value=2.0, points=2)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
run_async(
store.answer(
player, quiz, attempt, first["uid"], {"answer_text": "1", "option_uids": []}
)
)
finished, won = store.finish(quiz, store.get_attempt(attempt["uid"]))
assert won is True
assert finished["status"] == "completed"
assert float(finished["score_points"]) == 2.0
assert float(finished["score_percent"]) == 50.0
assert int(finished["passed"]) == 1
def test_finishing_twice_wins_only_once(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
store.finish(quiz, attempt)
_, won = store.finish(quiz, store.get_attempt(attempt["uid"]))
assert won is False
assert int(store.get_quiz(quiz["uid"])["attempt_count"]) == 1
def test_an_expired_attempt_refuses_further_answers(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, time_limit_seconds=60)
question = _numeric(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
get_table("quiz_attempts").update({"uid": attempt["uid"], "expires_at": past}, ["uid"])
with pytest.raises(QuizError):
run_async(
store.answer(
player,
quiz,
store.get_attempt(attempt["uid"]),
question["uid"],
{"answer_text": "1", "option_uids": []},
)
)
assert store.get_attempt(attempt["uid"])["status"] == "expired"
def test_expiry_happens_lazily_on_a_read(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, time_limit_seconds=60)
_numeric(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
get_table("quiz_attempts").update({"uid": attempt["uid"], "expires_at": past}, ["uid"])
serialized = store.serialize_attempt(quiz, store.get_attempt(attempt["uid"]), player)
assert serialized["status"] == "expired"
def test_the_serializer_withholds_the_answer_key_from_a_player(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, reveal_answers=0)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
payload = store.serialize_attempt(quiz, attempt, player)
question = payload["questions"][0]
assert "correct_boolean" not in question
assert "expected_answer" not in question
assert all("is_correct" not in option for option in question["options"])
def test_the_serializer_gives_the_owner_the_answer_key(local_db):
owner = _user("qs")
quiz = _quiz(owner, reveal_answers=0)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(owner, quiz)
payload = store.serialize_attempt(quiz, attempt, owner)
question = payload["questions"][0]
assert any("is_correct" in option for option in question["options"])
def test_the_serializer_reveals_an_answered_question_when_configured(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, reveal_answers=1)
question = _numeric(quiz["uid"], value=3.0)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
run_async(
store.answer(
player, quiz, attempt, question["uid"], {"answer_text": "3", "option_uids": []}
)
)
payload = store.serialize_attempt(quiz, store.get_attempt(attempt["uid"]), player)
assert "numeric_value" in payload["questions"][0]
def test_matching_choices_are_exposed_without_the_pairing(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, reveal_answers=0)
store.add_question(
quiz["uid"],
{
"kind": "matching",
"prompt": "Match",
"points": 1,
"options": [
{"label": "a", "match_value": "A", "is_correct": False},
{"label": "b", "match_value": "B", "is_correct": False},
],
},
)
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
attempt = store.start_attempt(player, quiz)
question = store.serialize_attempt(quiz, attempt, player)["questions"][0]
assert sorted(question["match_choices"]) == ["A", "B"]
assert all("match_value" not in option for option in question["options"])
def test_the_cascade_removes_every_child_under_one_stamp(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
store.start_attempt(player, store.get_quiz(quiz["uid"]))
stamp = datetime.now(timezone.utc).isoformat()
store.cascade_questions(quiz["uid"], owner["uid"], stamp)
for table in ("quiz_questions", "quiz_options", "quiz_attempts", "quiz_answers"):
live = get_table(table).count(quiz_uid=quiz["uid"], deleted_at=None)
stamped = get_table(table).count(quiz_uid=quiz["uid"], deleted_at=stamp)
assert live == 0
assert stamped > 0
def test_prune_attempts_keeps_completed_records(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
quiz = store.get_quiz(quiz["uid"])
kept = store.start_attempt(player, quiz)
store.finish(quiz, kept)
future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
store.prune_attempts(future)
assert store.get_attempt(kept["uid"]) is not None
def test_prune_attempts_removes_an_abandoned_one(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner)
_choice(quiz["uid"])
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
future = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat()
store.prune_attempts(future)
assert store.get_attempt(attempt["uid"]) is None
def test_the_attempt_order_is_persisted_as_json(local_db):
owner = _user("qs")
player = _user("qs")
quiz = _quiz(owner, shuffle_questions=1)
_numeric(quiz["uid"], value=1.0)
_numeric(quiz["uid"], value=2.0)
_numeric(quiz["uid"], value=3.0)
store.publish_quiz(quiz["uid"])
attempt = store.start_attempt(player, store.get_quiz(quiz["uid"]))
order = json.loads(attempt["question_order"])
assert sorted(order) == sorted(store.question_uids(quiz["uid"]))
assert store.attempt_order(store.get_attempt(attempt["uid"])) == order
View File
+100
View File
@@ -0,0 +1,100 @@
# retoor <retoor@molodetz.nl>
import re
from pathlib import Path
from devplacepy.templating import (
format_coins,
format_duration,
format_exact,
format_number,
format_speed,
)
FORMAT_JS = Path(__file__).resolve().parents[3] / "devplacepy" / "static" / "js" / "Format.js"
PARITY_VALUES = (
0,
1,
42,
999,
1000,
12345,
999999,
1000000,
1050000,
1500000,
9990000,
12500000,
150000000,
1000000000,
2340000000,
1000000000000,
5500000000000,
-42,
-1500000,
)
def _client_number(value):
amount = int(value)
magnitude = abs(amount)
if magnitude < 1000000:
return f"{amount:,}"
for limit, suffix in ((10**12, "T"), (10**9, "B"), (10**6, "M")):
if magnitude >= limit:
scaled = amount / limit
digits = 2 if abs(scaled) < 10 else 1
text = f"{scaled:.{digits}f}".rstrip("0").rstrip(".")
return f"{text}{suffix}"
return f"{amount:,}"
def test_server_and_client_number_formatting_agree():
for value in PARITY_VALUES:
assert format_number(value) == _client_number(value), value
def test_thousands_separated_below_the_compact_threshold():
assert format_number(999) == "999"
assert format_number(1000) == "1,000"
assert format_number(999999) == "999,999"
def test_compact_above_the_threshold():
assert format_number(1000000) == "1M"
assert format_number(1500000) == "1.5M"
assert format_number(150000000) == "150M"
assert format_number(2340000000) == "2.34B"
assert format_number(5500000000000) == "5.5T"
def test_coins_and_exact_helpers():
assert format_coins(1500000) == "1.5Mc"
assert format_coins(250) == "250c"
assert format_exact(1500000) == "1,500,000"
assert format_exact(0) == "0"
def test_speed_drops_trailing_zeros():
assert format_speed(1.0) == "1x"
assert format_speed(1.25) == "1.25x"
assert format_speed(2.5) == "2.5x"
def test_duration_matches_the_client_shape():
assert format_duration(0) == "0s"
assert format_duration(45) == "45s"
assert format_duration(90) == "1m 30s"
assert format_duration(3600) == "1h 0m"
assert format_duration(7325) == "2h 2m"
def test_client_module_keeps_the_same_thresholds():
source = FORMAT_JS.read_text()
assert "COMPACT_FROM = 1000000" in source
assert 'suffix: "T"' in source and 'suffix: "B"' in source and 'suffix: "M"' in source
assert 'toLocaleString("en-GB")' in source
assert re.search(r"static coins\(", source)
assert re.search(r"static duration\(", source)
+33
View File
@@ -0,0 +1,33 @@
# retoor <retoor@molodetz.nl>
from devplacepy.constants import REACTION_EMOJI
from devplacepy.templating import reaction_emojis
UNICORN = "\U0001F984"
ROBOT = "\U0001F916"
def test_no_reactions_renders_the_quick_pick_palette():
assert reaction_emojis(None) == REACTION_EMOJI
assert reaction_emojis({}) == REACTION_EMOJI
assert reaction_emojis({"counts": {}, "mine": []}) == REACTION_EMOJI
def test_palette_emoji_are_not_duplicated():
palette_emoji = REACTION_EMOJI[0]
assert reaction_emojis({"counts": {palette_emoji: 3}, "mine": [palette_emoji]}) == REACTION_EMOJI
def test_off_palette_emoji_are_appended_after_the_palette():
assert reaction_emojis({"counts": {UNICORN: 2}, "mine": []}) == REACTION_EMOJI + [UNICORN]
def test_own_off_palette_reaction_is_included_without_a_count():
assert reaction_emojis({"counts": {}, "mine": [ROBOT]}) == REACTION_EMOJI + [ROBOT]
def test_counted_and_own_off_palette_emoji_appear_once_each():
emojis = reaction_emojis({"counts": {UNICORN: 1, ROBOT: 4}, "mine": [ROBOT]})
assert emojis == REACTION_EMOJI + [UNICORN, ROBOT]
assert len(emojis) == len(set(emojis))