Two shapes, because a plan is asked to do two different things. Papers are rehearsal: each block is drawn to the ABP's published weights, so sitting one says something about how you would do on the day. Domains are study: the board's twenty-four content areas in its own order and carrying its own titles, each given the share of the plan the board gives it on the exam. Both were written, then run against the real bank, which found two bugs a unit test on a clean fixture would not have. Domains 19 and 20 — nephrology and genitourinary — both map to our "Nephrology & Urology", so a question sat in two pools and was dealt twice; the deal now keeps a record of what has gone. And chunking every question a domain has into blocks of forty gave preventive care six blocks and the plan a hundred and sixty, which is not a plan: blocks are shared out by weight, with at least one per domain so nothing the board examines is left out. Built on the live bank alongside what was already there: Boards: Full Papers (12 × 40) and Boards: By Content Domain (27 blocks, 1069 questions). Nothing existing was touched. Psychosocial Issues and Child Abuse and Neglect — 6% of the paper between them — had no category of ours at all, so they could contribute nothing. Both now exist, with sub-topics named from the board's own subdomains, and all 24 domains map to categories. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
116 lines
5.8 KiB
Python
116 lines
5.8 KiB
Python
"""Plans built to a board's content outline.
|
|
|
|
Two shapes and one rule they share: no question appears twice in a plan. Two
|
|
domains can map to the same categories of ours — the board splits genitourinary
|
|
from nephrology and we do not — so a pool-per-domain is not enough on its own.
|
|
"""
|
|
import random
|
|
import unittest
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
import test_quiz_builder # noqa: F401 — imports every model
|
|
from app.database import Base
|
|
from app.models.exam import BlueprintCategoryLink, Exam, ExamBlueprint, QuestionExamLink
|
|
from app.models.question import Question
|
|
from app.models.question_category import QuestionCategory
|
|
from app.models.user import User
|
|
from app.services import exam_blueprint
|
|
|
|
|
|
class BlueprintPlanTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool)
|
|
Base.metadata.create_all(self.engine)
|
|
self.db = Session(self.engine)
|
|
self.db.add(User(id=1, name="Mod", email="m@example.test",
|
|
hashed_password="unused", role="moderator"))
|
|
self.db.add(Exam(id=1, slug="boards", name="Boards"))
|
|
self.db.add_all([QuestionCategory(id=1, name="Kidney", user_id=1),
|
|
QuestionCategory(id=2, name="Chest", user_id=1)])
|
|
self.db.flush()
|
|
for qid in range(1, 121):
|
|
self.db.add(Question(id=qid, question_category_id=1 if qid <= 60 else 2, user_id=1,
|
|
question_text=f"Q{qid}", question_type="mcq",
|
|
options=["a", "b"], correct_answer="a"))
|
|
self.db.add(QuestionExamLink(question_id=qid, exam_id=1))
|
|
# Two domains, one shared category: exactly the case that repeats.
|
|
self.db.add_all([
|
|
ExamBlueprint(id=1, exam_id=1, code="19", title="Nephrology", weight=Decimal("50"), sort_order=0),
|
|
ExamBlueprint(id=2, exam_id=1, code="20", title="Genitourinary", weight=Decimal("30"), sort_order=1),
|
|
ExamBlueprint(id=3, exam_id=1, code="12", title="Pulmonology", weight=Decimal("20"), sort_order=2),
|
|
])
|
|
self.db.flush()
|
|
self.db.add_all([BlueprintCategoryLink(blueprint_id=1, category_id=1),
|
|
BlueprintCategoryLink(blueprint_id=2, category_id=1),
|
|
BlueprintCategoryLink(blueprint_id=3, category_id=2)])
|
|
self.db.commit()
|
|
|
|
def tearDown(self):
|
|
self.db.close()
|
|
self.engine.dispose()
|
|
|
|
def test_papers_never_repeat_a_question_across_blocks(self):
|
|
papers = exam_blueprint.sample_papers(self.db, 1, 10, 6, rng=random.Random(3))
|
|
drawn = [q for ids, _ in papers for q in ids]
|
|
self.assertEqual(len(drawn), len(set(drawn)))
|
|
|
|
def test_two_domains_sharing_a_category_do_not_deal_the_same_question(self):
|
|
# Nephrology and Genitourinary both point at Kidney. Without a record
|
|
# of what has gone, a question is dealt once from each.
|
|
self.assertEqual(exam_blueprint.sample_papers(self.db, 1, 20, 0), [],
|
|
"asking for no papers draws nothing")
|
|
one = exam_blueprint.sample_papers(self.db, 1, 20, 1, rng=random.Random(3))
|
|
ids = one[0][0]
|
|
self.assertEqual(len(ids), len(set(ids)))
|
|
|
|
def test_a_paper_keeps_its_length_and_the_board_s_proportions(self):
|
|
papers = exam_blueprint.sample_papers(self.db, 1, 10, 1, rng=random.Random(3))
|
|
ids, report = papers[0]
|
|
self.assertEqual(len(ids), 10)
|
|
given = {row["code"]: row["given"] for row in report}
|
|
self.assertEqual(given["19"], 5) # 50%
|
|
self.assertEqual(given["12"], 2) # 20%
|
|
|
|
def test_the_plan_shortens_rather_than_repeating_when_the_bank_runs_out(self):
|
|
# Twelve papers of forty is four hundred and eighty from a bank of 120.
|
|
papers = exam_blueprint.sample_papers(self.db, 1, 40, 12, rng=random.Random(3))
|
|
drawn = [q for ids, _ in papers for q in ids]
|
|
self.assertEqual(len(drawn), len(set(drawn)))
|
|
self.assertLessEqual(len(drawn), 120)
|
|
|
|
def test_domain_blocks_are_sized_by_weight_not_by_what_is_lying_about(self):
|
|
# Chunking every question into blocks gave preventive care six blocks
|
|
# and a plan of a hundred and sixty. Weight decides how many.
|
|
blocks = exam_blueprint.domain_blocks(self.db, 1, 10, 6, rng=random.Random(3))
|
|
counts = {}
|
|
for code, _, ids in blocks:
|
|
counts[code] = counts.get(code, 0) + 1
|
|
self.assertGreater(counts["19"], counts["12"], "the heavier domain gets more blocks")
|
|
ids = [q for _, _, b in blocks for q in b]
|
|
self.assertEqual(len(ids), len(set(ids)))
|
|
|
|
def test_every_domain_with_questions_gets_at_least_one_block(self):
|
|
blocks = exam_blueprint.domain_blocks(self.db, 1, 10, 2, rng=random.Random(3))
|
|
# Two blocks asked for, three domains: nothing the board examines is
|
|
# left out of a plan built from its outline.
|
|
self.assertEqual({code for code, _, _ in blocks}, {"19", "20", "12"})
|
|
|
|
def test_blocks_carry_the_board_s_own_titles(self):
|
|
blocks = exam_blueprint.domain_blocks(self.db, 1, 10, 6, rng=random.Random(3))
|
|
self.assertTrue(any(title.startswith("Nephrology") for _, title, _ in blocks))
|
|
self.assertTrue(any("of" in title for _, title, _ in blocks), "a split domain says so")
|
|
|
|
def test_an_exam_with_no_blueprint_builds_nothing(self):
|
|
self.db.add(Exam(id=2, slug="step", name="Step"))
|
|
self.db.commit()
|
|
self.assertEqual(exam_blueprint.sample_papers(self.db, 2, 40, 4), [])
|
|
self.assertEqual(exam_blueprint.domain_blocks(self.db, 2, 40, 4), [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|