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