`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
278 lines
14 KiB
Python
278 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.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()
|