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
164 lines
8 KiB
Python
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()
|