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
277 lines
14 KiB
Python
277 lines
14 KiB
Python
"""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()
|