pdf-quiz-generator/backend/tests/test_study_plan_sessions.py
Daniel ac92793600 feat: study-plan blocks as modules, sessions that know their block
From the three recordings and the AMBOSS screenshots.

Study plans
- Blocks of about 40, split evenly: 202 questions is six blocks of
  33-34, not five of 50 and one of 2. Reseeded (no progress or reading
  existed yet); the seeder now splits the same way.
- A block has its own page, laid out as a course module: the plan's
  blocks down the left, this block's reading then its session in the
  middle, back / previous / next along the bottom. Study or exam mode
  is chosen there, before the session exists; afterwards the mode is
  shown, not offered. The plan page is the table of contents and links
  into blocks rather than starting anything.
- Progress on a block comes from the same /quizzes/sessions row the
  Sessions page shows, so the two cannot disagree.

Sessions <-> plans
- A session started from a block carries its place in the plan: the
  session list and the analysis both return `plan` (plan, block,
  position, previous and next block). The analysis shows a strip with
  the way back to the block and on to the next one.
- Submitting a session marks its block complete. Nothing ever set
  completed_at before — every block read as unfinished forever.

Recommendations
- Framed by the learner's chosen study objective: answers and bank
  material linked to a different exam are left out, and the page is
  titled for the exam. Unlinked material stays in, as elsewhere.

Backend 216/216, frontend 257/258 (the one failure is
ArticleSplitView, which is timing-flaky under the full run and is
unrelated to this change; being checked separately).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 04:31:21 +02:00

164 lines
8 KiB
Python

"""A study-plan block, once started, is a session — and knows where it came from.
Disposable SQLite; no network or AI. Covers the join between plans and
sessions: the session list and the analysis carry the block's place in the
plan, submitting marks the block done (nothing did before), and the
recommendations are framed by the study objective rather than by whatever
happened to be answered.
"""
import sys
import unittest
from datetime import datetime
from types import ModuleType
from unittest.mock import patch
import test_quiz_builder as fixtures
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.exam import Exam, QuestionExamLink
from app.models.quiz import Quiz
from app.models.study_plan import StudyPlan, StudyPlanBlock, StudyPlanBlockProgress
from app.routers import attempts, study_plans, study_tools
from app.services.study_plan_context import mark_block_complete, plan_context_for_quizzes
with patch.dict(sys.modules, {"app.services.quiz_service": ModuleType("app.services.quiz_service")}):
from app.routers import quizzes
class StudyPlanSessionTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
for router, prefix in [(study_plans.router, "/study-plans"), (quizzes.router, "/quizzes"),
(attempts.router, "/attempts"), (study_tools.router, "/study-tools")]:
self.client.app.include_router(router, prefix=prefix)
self.db.add(StudyPlan(id=1, slug="board-review-i", name="Board Review I", kind="set", is_published=1))
self.db.flush()
self.db.add_all([
StudyPlanBlock(id=10, plan_id=1, position=1, title="Block 1", question_ids=[1, 2]),
# Both blocks use questions the owner can see; a block whose questions
# are all private to someone else cannot be started, by design.
StudyPlanBlock(id=11, plan_id=1, position=2, title="Block 2", question_ids=[2]),
])
self.db.commit()
self.bank.user = self.bank.owner
def tearDown(self):
self.bank.tearDown()
def start(self, block_id=10, mode="learning"):
response = self.client.post(f"/study-plans/blocks/{block_id}/start", params={"mode": mode})
self.assertEqual(response.status_code, 200, response.text)
return response.json()["id"]
# ── sessions know their block ─────────────────────────────────────────────
def test_a_started_block_appears_in_sessions_with_its_place_in_the_plan(self):
quiz_id = self.start()
rows = {row["quiz_id"]: row for row in self.client.get("/quizzes/sessions").json()}
self.assertIn(quiz_id, rows)
plan = rows[quiz_id]["plan"]
self.assertEqual((plan["plan_name"], plan["block_title"]), ("Board Review I", "Block 1"))
self.assertEqual((plan["block_position"], plan["block_count"]), (1, 2))
self.assertIsNone(plan["prev_block_id"])
self.assertEqual(plan["next_block_id"], 11)
self.assertFalse(plan["completed"])
def test_a_session_with_no_plan_behind_it_says_so_plainly(self):
self.assertEqual(plan_context_for_quizzes(self.db, self.bank.owner.id, [999]), {})
def test_the_last_block_has_no_next(self):
quiz_id = self.start(11)
context = plan_context_for_quizzes(self.db, self.bank.owner.id, [quiz_id])[quiz_id]
self.assertEqual(context["prev_block_id"], 10)
self.assertIsNone(context["next_block_id"])
def test_plan_context_is_per_learner(self):
quiz_id = self.start()
# The same quiz means nothing to someone else's progress.
self.assertEqual(plan_context_for_quizzes(self.db, self.bank.peer.id, [quiz_id]), {})
# ── completion ────────────────────────────────────────────────────────────
def test_submitting_marks_the_block_done_once_and_keeps_the_first_date(self):
quiz_id = self.start()
row = self.db.query(StudyPlanBlockProgress).filter_by(quiz_id=quiz_id).one()
self.assertIsNone(row.completed_at)
mark_block_complete(self.db, self.bank.owner.id, quiz_id)
self.db.commit()
first = self.db.get(StudyPlanBlockProgress, row.id).completed_at
self.assertIsNotNone(first)
mark_block_complete(self.db, self.bank.owner.id, quiz_id)
self.db.commit()
self.assertEqual(self.db.get(StudyPlanBlockProgress, row.id).completed_at, first)
blocks = {b["title"]: b for b in self.client.get("/study-plans/1").json()["blocks"]}
self.assertTrue(blocks["Block 1"]["completed"])
self.assertFalse(blocks["Block 2"]["completed"])
def test_marking_a_quiz_with_no_block_is_a_no_op(self):
mark_block_complete(self.db, self.bank.owner.id, 12345) # must not raise
self.db.commit()
def test_the_analysis_carries_the_plan_context(self):
quiz_id = self.start()
attempt = QuizAttempt(id=700, quiz_id=quiz_id, user_id=self.bank.owner.id, mode="learning",
total_questions=2, score=1, started_at=datetime(2026, 3, 1, 9),
completed_at=datetime(2026, 3, 1, 10))
self.db.add(attempt)
self.db.add_all([
AttemptAnswer(attempt_id=700, question_id=1, user_answer="A", is_correct=True),
AttemptAnswer(attempt_id=700, question_id=2, user_answer="B", is_correct=False),
])
self.db.commit()
response = self.client.get("/attempts/700/analysis")
self.assertEqual(response.status_code, 200, response.text)
plan = response.json()["plan"]
self.assertEqual(plan["plan_id"], 1)
self.assertEqual(plan["block_id"], 10)
self.assertEqual(plan["next_block_id"], 11)
# ── recommendations follow the study objective ────────────────────────────
def test_recommendations_are_framed_by_the_active_exam(self):
self.db.add_all([Exam(id=1, slug="boards", name="Pediatrics Boards"),
Exam(id=2, slug="step", name="Step 1")])
self.db.flush()
# Question 1 belongs to the boards, 2 to the step exam, 5 to neither.
self.db.add_all([QuestionExamLink(question_id=1, exam_id=1),
QuestionExamLink(question_id=2, exam_id=2)])
quiz = Quiz(id=300, title="Mixed", user_id=self.bank.owner.id, mode="learning",
questions_count=2, is_published=1)
self.db.add(quiz)
self.db.flush()
self.db.add(QuizAttempt(id=701, quiz_id=300, user_id=self.bank.owner.id, mode="learning",
total_questions=2, score=1, started_at=datetime(2026, 3, 2, 9),
completed_at=datetime(2026, 3, 2, 10)))
self.db.add_all([
AttemptAnswer(attempt_id=701, question_id=1, user_answer="A", is_correct=True),
AttemptAnswer(attempt_id=701, question_id=2, user_answer="B", is_correct=False),
])
self.db.commit()
# No objective chosen: everything counts.
everything = self.client.get("/study-tools/recommendations").json()
self.assertIsNone(everything["exam_name"])
self.assertEqual(everything["total_answered"], 2)
# Studying for the boards: the step-exam answer is left out of the
# picture, and the bank it is measured against shrinks to match.
self.bank.owner.active_exam_id = 1
self.db.commit()
boards = self.client.get("/study-tools/recommendations").json()
self.assertEqual(boards["exam_name"], "Pediatrics Boards")
self.assertEqual(boards["total_answered"], 1)
self.assertEqual(boards["overall_accuracy"], 100.0)
self.assertEqual(boards["bank_total"], everything["bank_total"] - 1)
if __name__ == "__main__":
unittest.main()