pdf-quiz-generator/backend/tests/test_bank_cleanup.py
Daniel 3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:27:51 +02:00

277 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The two bank-repair scripts: OCR units, lab tables, and stem-image triage.
Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests.
No network and no AI: the vision classifier is never reached, only the
deterministic rule that decides most of the images on the text alone.
"""
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import sys
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models.media import MediaAsset # noqa — health report walks every embeddable table.
from app.models.question import Question, QuestionVersion
from app.models.user import User
from scripts import fix_lab_formatting as labs
from scripts import triage_question_images as images
# Question 3333 as it came out of the scanner: two mangled units, a lost
# superscript, and a per-decilitre spelling of a per-litre value.
MANGLED_STEM = (
"Laboratory studies demonstrate the following: "
"Complete blood cell count, within normal limits; "
"White blood cell count, 3,500/µL (3.5 x 109/L); "
"Hemoglobin, 13.5 g/dL (135 g/L); "
"Chloride, 105 inEq/L (105 mrnol/L); "
"Phosphorus, 1.8 mg/dL (0.58 mmol/dL); "
"Glucose, 115 mg/dL (6.4 mmol/dL). "
"Of the following, you are MOST likely to recommend")
class UnitRepairTests(unittest.TestCase):
def test_the_repairs_fire_on_real_mangled_input(self):
fixed, fired = labs.repair_units(MANGLED_STEM)
self.assertIn("105 mEq/L (105 mmol/L)", fixed)
self.assertIn("0.58 mmol/L", fixed)
self.assertIn("6.4 mmol/L", fixed)
self.assertIn("3.5 × 10⁹/L", fixed)
self.assertEqual(len(fired), 4)
# Everything that was already right is untouched.
self.assertIn("13.5 g/dL (135 g/L)", fixed)
self.assertIn("3,500/µL", fixed)
def test_repairs_are_idempotent(self):
once, _ = labs.repair_units(MANGLED_STEM)
twice, fired = labs.repair_units(once)
self.assertEqual(once, twice)
self.assertEqual(fired, [])
def test_only_real_powers_of_ten_get_a_superscript(self):
# 10³, 10⁶, 10⁹ and 10¹² name units; "10¹" is a lost digit, not a lost
# superscript, and inventing one would invent a number.
for source, expected in [
("Platelets, 86 x 103/µL", "86 × 10³/µL"),
("Red blood cells, 4.5 x 106/µL", "4.5 × 10⁶/µL"),
("Red blood cells, 4.5 x 1012/L", "4.5 × 10¹²/L"),
]:
self.assertIn(expected, labs.repair_units(source)[0], source)
unchanged = "White blood cell count, 10.0 x 101/µL"
self.assertEqual(labs.repair_units(unchanged)[0], unchanged)
# A count that merely happens to read "100/µL" is not a power of ten.
plain = "Eosinophils, 100/µL"
self.assertEqual(labs.repair_units(plain)[0], plain)
def test_conservative_about_ambiguous_units(self):
# "3,000/mL" is almost certainly "/µL" and "10A" a mangled exponent,
# but neither correction is certain, so neither is made. The lost
# superscript beside them is unambiguous and is still fixed.
out, _ = labs.repair_units("White blood cells 3,000/mL (3 x 109/L)")
self.assertEqual(out, "White blood cells 3,000/mL (3 × 10⁹/L)")
for source in ["White blood cell count of 14,600/µL (14.6 x 10A)",
"a 4-year-old boy", "the 2013 guideline",
"Creatinine, 1.0 mg/dL (88.4 mcmol/L)"]:
self.assertEqual(labs.repair_units(source)[0], source, source)
class LabTableTests(unittest.TestCase):
def test_a_full_panel_becomes_a_table(self):
fixed, _ = labs.repair_units(MANGLED_STEM)
out, panels = labs.tabulate_panels(fixed)
self.assertEqual(panels, 1)
self.assertIn("| Test | Result | SI units |", out)
self.assertIn("| Chloride | 105 mEq/L | 105 mmol/L |", out)
self.assertIn("| Complete blood cell count | within normal limits | |", out)
# The prose on either side survives, and the table stands apart from it.
self.assertTrue(out.startswith("Laboratory studies demonstrate the following:"))
self.assertTrue(out.rstrip().endswith("you are MOST likely to recommend"))
self.assertIn("\n\n|", out)
def test_tabulating_is_idempotent(self):
once, _ = labs.tabulate_panels(labs.repair_units(MANGLED_STEM)[0])
twice, panels = labs.tabulate_panels(once)
self.assertEqual((twice, panels), (once, 0))
def test_a_differential_is_left_as_prose(self):
# "29% segmented neutrophils, 28% bands" reads the other way round;
# pairing name with value here would shift every result by one.
text = ("Laboratory data shows: Differential count, 29% segmented neutrophils, "
"28% bands, 30% lymphocytes, 11% monocytes, 2% eosinophils")
self.assertEqual(labs.tabulate_panels(text), (text, 0))
def test_a_sentence_of_vital_signs_is_left_as_prose(self):
text = ("On examination, his temperature is 37.2°C, heart rate is 96 beats/min, "
"and respiratory rate is 18 breaths/min. The boy appears ill.")
self.assertEqual(labs.tabulate_panels(text), (text, 0))
def test_a_short_run_is_left_as_prose(self):
text = "Initial studies show: Sodium, 139 mEq/L; Potassium, 3.5 mEq/L. He is admitted."
self.assertEqual(labs.tabulate_panels(text), (text, 0))
def test_a_reference_range_annotates_the_result_above_it(self):
text = ("Laboratory tests produce the following results: "
"Hemoglobin, 12.8 g/dL (128 g/L); "
"White blood cell count, 6,500/µL (6.5 × 10⁹/L); "
"Aspartate aminotransferase, 125 U/L; reference range, ≤40 U/L; "
"Alanine aminotransferase, 35 U/L; reference range, ≤30 U/L; "
"Total bilirubin, 2.0 mg/dL (34.2 µmol/L). Results are normal.")
out, panels = labs.tabulate_panels(text)
self.assertEqual(panels, 1)
self.assertIn("| Aspartate aminotransferase | 125 U/L (reference range, ≤40 U/L) |", out)
self.assertNotIn("| reference range |", out)
def test_an_si_column_only_holds_a_single_value(self):
items = [("Sodium", "139 mEq/L", "139 mmol/L")]
self.assertIn("| Sodium | 139 mEq/L | 139 mmol/L |", labs.render_table(items))
# A differential in brackets opens with a digit too, but it is not SI.
text = ("Results: White blood cells, 147/µL (4% segmented, 83% lymphocytes); "
"Red blood cells, 10/µL; Glucose, 25 mg/dL (1.4 mmol/L); "
"Protein, 179 mg/dL. Cultures are pending.")
out, _ = labs.tabulate_panels(text)
self.assertIn("| White blood cells | 147/µL (4% segmented, 83% lymphocytes) | |", out)
class SqliteBankTests(unittest.TestCase):
"""A disposable bank, with the scripts pointed at it instead of the real DB."""
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="mod@example.test",
hashed_password="unused", role="moderator"))
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def add_question(self, **fields):
question = Question(question_type="mcq", options=["a", "b"], correct_answer="a", **fields)
self.db.add(question)
self.db.commit()
return question
def run_script(self, module, *argv):
with patch.object(module, "SessionLocal", lambda: self.db), \
patch.object(sys, "argv", ["script", *argv]):
return module.main()
class LabScriptTests(SqliteBankTests):
def test_an_edit_snapshots_the_stem_before_changing_it(self):
question = self.add_question(id=1, question_text=MANGLED_STEM,
explanation="Refeeding syndrome.")
self.assertEqual(self.run_script(labs, "--apply"), 0)
versions = self.db.query(QuestionVersion).filter_by(question_id=1).all()
self.assertEqual(len(versions), 1)
# The snapshot holds the text as it was, so the edit can be undone.
self.assertEqual(versions[0].snapshot["question_text"], MANGLED_STEM)
stem = self.db.get(Question, 1).question_text
self.assertIn("105 mEq/L (105 mmol/L)", stem)
self.assertNotIn("inEq", stem)
def test_a_dry_run_writes_nothing(self):
self.add_question(id=1, question_text=MANGLED_STEM)
self.assertEqual(self.run_script(labs), 0)
self.assertEqual(self.db.query(QuestionVersion).count(), 0)
self.assertEqual(self.db.get(Question, 1).question_text, MANGLED_STEM)
def test_a_clean_stem_is_neither_touched_nor_versioned(self):
clean = "A 5-year-old girl has a temperature of 38.5°C and a heart rate of 120 beats/min."
self.add_question(id=1, question_text=clean)
self.assertEqual(self.run_script(labs, "--apply"), 0)
self.assertEqual(self.db.query(QuestionVersion).count(), 0)
self.assertEqual(self.db.get(Question, 1).question_text, clean)
def test_tables_are_opt_in(self):
self.add_question(id=1, question_text=MANGLED_STEM)
self.run_script(labs, "--apply")
self.assertNotIn("| Test |", self.db.get(Question, 1).question_text)
self.run_script(labs, "--apply", "--tables")
self.assertIn("| Test | Result | SI units |", self.db.get(Question, 1).question_text)
class ImageTriageRuleTests(unittest.TestCase):
def test_the_rule_fires_only_when_the_explanation_alone_names_a_figure(self):
verdict = images.deterministic_verdict
self.assertEqual(verdict("The rash shown below is on his trunk.",
"The photograph shows erythema migrans."), "keep")
self.assertEqual(verdict("He has a rash on his trunk.",
"The photograph shows erythema migrans."), "remove")
self.assertEqual(verdict("He has a rash on his trunk.",
"Erythema migrans is the classic finding."), "unknown")
# A stem that names a figure keeps its image even when the critique
# names one too — both halves have a figure, and only one is attached.
self.assertEqual(verdict("The lesion pictured is tender.",
"The figure shows the same lesion."), "keep")
def test_prep_figure_labels_decide_where_the_figure_was_printed(self):
verdict = images.deterministic_verdict
# "Item Q22" is printed beside the question, "Item C28" beside the critique.
self.assertEqual(verdict("The ankle is in a varus position (Item Q22).",
"Clubfoot is treated with casting."), "keep")
self.assertEqual(verdict("A mother presents with premature rupture of membranes.",
"Survival rises with antenatal steroids (Item C28)."), "remove")
# The scanner reads a capital I as a lowercase L.
self.assertEqual(verdict("The rash is widespread (ltem Q37A).", ""), "keep")
def test_empty_text_is_never_a_removal(self):
self.assertEqual(images.deterministic_verdict("", ""), "unknown")
self.assertEqual(images.deterministic_verdict(None, None), "unknown")
class ImageTriageScriptTests(SqliteBankTests):
def test_only_the_explanation_only_case_loses_its_image(self):
spoiler = self.add_question(
id=1, question_text="He has a rash on his trunk.",
explanation="The photograph shows erythema migrans.",
image_path="images/doc_5/page_26_img_0.jpeg")
needed = self.add_question(
id=2, question_text="The rash shown below is on his trunk.",
explanation="Erythema migrans.", image_path="images/doc_5/page_27_img_0.jpeg")
unknown = self.add_question(
id=3, question_text="He has a rash.", explanation="Erythema migrans.",
image_path="images/doc_5/page_28_img_0.jpeg")
spoiler_id, needed_id, unknown_id = spoiler.id, needed.id, unknown.id
self.assertEqual(self.run_script(images, "--apply", "--no-vision"), 0)
paths = {q.id: q.image_path for q in self.db.query(Question).all()}
self.assertIsNone(paths[spoiler_id])
self.assertEqual(paths[needed_id], "images/doc_5/page_27_img_0.jpeg")
# Without the model there is nothing to say about the unknown, so it stays.
self.assertEqual(paths[unknown_id], "images/doc_5/page_28_img_0.jpeg")
# The removal is undoable: the path is in the snapshot.
version = self.db.query(QuestionVersion).filter_by(question_id=1).one()
self.assertEqual(version.snapshot["image_path"], "images/doc_5/page_26_img_0.jpeg")
def test_a_dry_run_removes_nothing(self):
question = self.add_question(
id=1, question_text="He has a rash.",
explanation="The photograph shows erythema migrans.",
image_path="images/doc_5/page_26_img_0.jpeg")
question_id = question.id
self.assertEqual(self.run_script(images, "--no-vision"), 0)
self.assertEqual(self.db.get(Question, question_id).image_path,
"images/doc_5/page_26_img_0.jpeg")
self.assertEqual(self.db.query(QuestionVersion).count(), 0)
def test_rerunning_changes_nothing_further(self):
self.add_question(id=1, question_text="He has a rash.",
explanation="The photograph shows erythema migrans.",
image_path="images/doc_5/page_26_img_0.jpeg")
self.run_script(images, "--apply", "--no-vision")
self.run_script(images, "--apply", "--no-vision")
self.assertEqual(self.db.query(QuestionVersion).count(), 1)
if __name__ == "__main__":
unittest.main()