"""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()