pdf-quiz-generator/backend/tests/test_bank_cleanup.py
Daniel 5d59e00144 refactor: remove per-question sharing
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.

So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.

The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.

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

278 lines
14 KiB
Python
Raw 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.course import Course # noqa — Quiz.course_id FK needs the table in metadata.
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()