Spaces:
Sleeping
Sleeping
| """ | |
| LabCard AI β Biomarker Parser Tests | |
| Covers all 4 real-world Indian lab report format variants. | |
| Run with: pytest tests/test_parser.py -v | |
| """ | |
| import sys | |
| import os | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | |
| import pytest | |
| from app.core.biomarker_parser import ( | |
| clean_test_name, | |
| parse_biomarkers, | |
| parse_range, | |
| parse_value, | |
| ) | |
| from app.models.biomarker import BiomarkerRaw | |
| # ββ parse_value tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestParseValue: | |
| def test_standard_float(self): | |
| assert parse_value("10.2") == 10.2 | |
| def test_integer(self): | |
| assert parse_value("9800") == 9800.0 | |
| def test_indian_comma(self): | |
| assert parse_value("10,200") == 10200.0 | |
| def test_large_indian_comma(self): | |
| assert parse_value("1,85,000") == 185000.0 | |
| def test_less_than(self): | |
| # "<0.1" β half of 0.1 | |
| result = parse_value("<0.1") | |
| assert result == pytest.approx(0.05, abs=0.001) | |
| def test_greater_than(self): | |
| # ">100" β 101 | |
| assert parse_value(">100") == 101.0 | |
| def test_with_trailing_flag(self): | |
| # Some labs print "10.2 L" (L = Low) | |
| assert parse_value("10.2 L") == 10.2 | |
| assert parse_value("185000 H") == 185000.0 | |
| def test_invalid(self): | |
| assert parse_value("N/A") is None | |
| assert parse_value("") is None | |
| assert parse_value("abc") is None | |
| # ββ parse_range tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestParseRange: | |
| def test_hyphen_range(self): | |
| assert parse_range("13.0 - 17.0") == (13.0, 17.0) | |
| def test_endash_range(self): | |
| assert parse_range("13.0 β 17.0") == (13.0, 17.0) | |
| def test_no_spaces(self): | |
| assert parse_range("13.0-17.0") == (13.0, 17.0) | |
| def test_large_numbers(self): | |
| assert parse_range("150000-400000") == (150000.0, 400000.0) | |
| def test_less_than(self): | |
| low, high = parse_range("< 200") | |
| assert low is None | |
| assert high == 200.0 | |
| def test_less_than_no_space(self): | |
| low, high = parse_range("<200") | |
| assert low is None | |
| assert high == 200.0 | |
| def test_greater_than(self): | |
| low, high = parse_range("> 40") | |
| assert low == 40.0 | |
| assert high is None | |
| def test_decimal_small(self): | |
| assert parse_range("0.4 - 4.0") == (0.4, 4.0) | |
| def test_inverted_range(self): | |
| # Some labs print high first β parser should swap | |
| assert parse_range("17.0 - 13.0") == (13.0, 17.0) | |
| def test_empty(self): | |
| assert parse_range("") == (None, None) | |
| def test_unparseable(self): | |
| assert parse_range("Normal") == (None, None) | |
| # ββ clean_test_name tests βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestCleanTestName: | |
| def test_all_caps(self): | |
| result = clean_test_name("HEMOGLOBIN") | |
| assert result == "Hemoglobin" | |
| def test_strips_automated(self): | |
| result = clean_test_name("Hemoglobin (Automated)") | |
| assert result == "Hemoglobin" | |
| def test_strips_serum(self): | |
| result = clean_test_name("Creatinine (Serum)") | |
| assert result == "Creatinine" | |
| def test_strips_quantitative(self): | |
| result = clean_test_name("Vitamin B12 (Quantitative)") | |
| assert result == "Vitamin B12" | |
| def test_preserves_meaningful_paren(self): | |
| # (25-OH) is meaningful β keep it | |
| result = clean_test_name("Vitamin D (25-OH)") | |
| # Should not strip (25-OH) β it's not in the suffix list | |
| assert "25-Oh" in result or "25-oh" in result.lower() or "Vitamin D" in result | |
| def test_max_length(self): | |
| long_name = "A" * 60 | |
| result = clean_test_name(long_name) | |
| assert len(result) <= 50 | |
| def test_collapse_spaces(self): | |
| result = clean_test_name("Hemoglobin Count") | |
| assert " " not in result | |
| # ββ parse_biomarkers tests β 4 real-world formats ββββββββββββββββββββββββββββ | |
| class TestParseBiomarkers: | |
| """Test the main parser with all 4 Indian lab report formats.""" | |
| # Format A β Thyrocare colon-separated with brackets | |
| FORMAT_A = """ | |
| COMPLETE BLOOD COUNT (CBC) | |
| Hemoglobin : 10.2 g/dL [13.0 - 17.0] | |
| WBC Count : 9800 cells/uL [4000 - 11000] | |
| Platelet Count : 185000 /uL [150000 - 400000] | |
| MCV : 68 fL [80 - 100] | |
| """ | |
| # Format B β Dr. Lal tabular, no colon | |
| FORMAT_B = """ | |
| TEST RESULT UNIT REFERENCE RANGE | |
| HEMOGLOBIN (Hb) 10.2 g/dL 13.0 - 17.0 L | |
| WBC COUNT 9800 cells/uL 4000 - 11000 | |
| PLATELET COUNT 185000 /uL 150000 - 400000 | |
| MCV 68 fL 80 - 100 L | |
| """ | |
| # Format C β Apollo with Ref: prefix | |
| FORMAT_C = """ | |
| Haemoglobin 10.2 g/dL Ref: 13.0-17.0 | |
| WBC 9800 cells/uL Ref: 4000-11000 | |
| Platelet 185000 /uL Ref: 150000-400000 | |
| """ | |
| # Format D β inline abbreviated | |
| FORMAT_D = """ | |
| Hb: 10.2 g/dL (N: 13.0-17.0) | |
| WBC: 9800 cells/uL (N: 4000-11000) | |
| PLT: 185000 /uL (N: 150000-400000) | |
| """ | |
| def _get_parsed(self, text: str) -> dict[str, BiomarkerRaw]: | |
| """Helper β parse text and return dict by lowercase name.""" | |
| results = parse_biomarkers(text) | |
| return {r.name.lower(): r for r in results} | |
| def test_format_a_hemoglobin(self): | |
| parsed = self._get_parsed(self.FORMAT_A) | |
| # Find hemoglobin (may be titled) | |
| hb = next( | |
| (v for k, v in parsed.items() if "hemoglobin" in k or "hb" == k), | |
| None, | |
| ) | |
| assert hb is not None, f"Hemoglobin not found. Got: {list(parsed.keys())}" | |
| assert parse_value(hb.value_raw) == pytest.approx(10.2) | |
| assert "13" in hb.range_raw and "17" in hb.range_raw | |
| def test_format_a_wbc(self): | |
| parsed = self._get_parsed(self.FORMAT_A) | |
| wbc = next((v for k, v in parsed.items() if "wbc" in k or "white" in k), None) | |
| assert wbc is not None, f"WBC not found. Got: {list(parsed.keys())}" | |
| val = parse_value(wbc.value_raw) | |
| assert val == pytest.approx(9800.0) | |
| def test_format_a_platelet(self): | |
| parsed = self._get_parsed(self.FORMAT_A) | |
| plt = next((v for k, v in parsed.items() if "platelet" in k or "plt" in k), None) | |
| assert plt is not None, f"Platelet not found. Got: {list(parsed.keys())}" | |
| val = parse_value(plt.value_raw) | |
| assert val == pytest.approx(185000.0) | |
| def test_format_b_hemoglobin(self): | |
| parsed = self._get_parsed(self.FORMAT_B) | |
| hb = next((v for k, v in parsed.items() if "hemoglobin" in k or "hb" in k), None) | |
| assert hb is not None, f"Hemoglobin not found in Format B. Got: {list(parsed.keys())}" | |
| assert parse_value(hb.value_raw) == pytest.approx(10.2) | |
| def test_format_c_haemoglobin(self): | |
| parsed = self._get_parsed(self.FORMAT_C) | |
| hb = next((v for k, v in parsed.items() if "haemoglobin" in k or "hemoglobin" in k), None) | |
| assert hb is not None, f"Haemoglobin not found in Format C. Got: {list(parsed.keys())}" | |
| def test_deduplication(self): | |
| """Same test on two lines β keep the one with a range.""" | |
| text = """ | |
| Hemoglobin : 10.2 g/dL | |
| Hemoglobin : 10.2 g/dL [13.0 - 17.0] | |
| """ | |
| results = parse_biomarkers(text) | |
| hb_results = [r for r in results if "hemoglobin" in r.name.lower()] | |
| assert len(hb_results) == 1, "Deduplication failed β got duplicates" | |
| assert hb_results[0].range_raw != "", "Should keep entry with range" | |
| def test_skips_header_lines(self): | |
| text = """ | |
| TEST NAME RESULT UNIT REFERENCE RANGE | |
| Hemoglobin : 10.2 g/dL [13.0 - 17.0] | |
| PARAMETER VALUE NORMAL | |
| """ | |
| results = parse_biomarkers(text) | |
| names = [r.name.lower() for r in results] | |
| assert not any("test name" in n or "parameter" in n for n in names) | |
| assert any("hemoglobin" in n for n in names) | |
| def test_skips_empty_lines(self): | |
| text = "\n\n\n\nHemoglobin : 10.2 g/dL [13.0 - 17.0]\n\n" | |
| results = parse_biomarkers(text) | |
| assert len(results) >= 1 | |
| def test_empty_text(self): | |
| assert parse_biomarkers("") == [] | |
| assert parse_biomarkers(" ") == [] | |
| # ββ Integration: Demo report from frontend ββββββββββββββββββββββββββββββββββββ | |
| class TestDemoReport: | |
| """ | |
| Parse the actual demo-report.txt from the frontend project. | |
| Validates end-to-end that all major biomarkers are extracted. | |
| """ | |
| DEMO_REPORT = """ | |
| THYROCARE TECHNOLOGIES LIMITED | |
| Test Report | |
| Patient Name: Rahul Sharma | |
| Age/Gender: 28 Years / Male | |
| Sample Collected: 15 May 2025 | |
| Report Date: 16 May 2025 | |
| Lab No: TH9823451 | |
| AAROGYAM 1.3 (FULL BODY CHECKUP) | |
| COMPLETE BLOOD COUNT (CBC) | |
| Hemoglobin : 10.2 g/dL [13.0 - 17.0] | |
| RBC Count : 4.1 million/uL [4.5 - 5.5] | |
| WBC Count : 9800 cells/uL [4000 - 11000] | |
| Platelet Count : 185000 /uL [150000 - 400000] | |
| MCV : 68 fL [80 - 100] | |
| MCH : 22 pg [27 - 32] | |
| MCHC : 29 g/dL [31.5 - 34.5] | |
| Hematocrit (PCV) : 32 % [40 - 50] | |
| IRON STUDIES | |
| Serum Iron : 42 ug/dL [60 - 170] | |
| TIBC : 420 ug/dL [250 - 370] | |
| Serum Ferritin : 8 ng/mL [12 - 300] | |
| Transferrin Saturation: 10 % [20 - 50] | |
| THYROID PROFILE | |
| TSH : 2.8 uIU/mL [0.4 - 4.0] | |
| T3 (Triiodothyronine): 98 ng/dL [60 - 200] | |
| T4 (Thyroxine) : 7.2 ug/dL [4.5 - 12.5] | |
| VITAMINS | |
| Vitamin D (25-OH) : 14.2 ng/mL [30 - 100] | |
| Vitamin B12 : 185 pg/mL [200 - 900] | |
| Folic Acid : 5.8 ng/mL [3.0 - 17.0] | |
| DIABETES | |
| Fasting Blood Glucose: 88 mg/dL [70 - 100] | |
| HbA1c : 5.2 % [4.0 - 5.6] | |
| LIVER FUNCTION TEST | |
| SGPT (ALT) : 32 U/L [0 - 40] | |
| SGOT (AST) : 28 U/L [0 - 40] | |
| Alkaline Phosphatase : 78 U/L [44 - 147] | |
| Bilirubin Total : 0.8 mg/dL [0.2 - 1.2] | |
| Albumin : 4.1 g/dL [3.5 - 5.0] | |
| KIDNEY FUNCTION TEST | |
| Serum Creatinine : 0.9 mg/dL [0.7 - 1.3] | |
| Blood Urea Nitrogen : 14 mg/dL [7 - 20] | |
| Uric Acid : 5.8 mg/dL [3.5 - 7.2] | |
| LIPID PROFILE | |
| Total Cholesterol : 198 mg/dL [< 200] | |
| HDL Cholesterol : 38 mg/dL [> 40] | |
| LDL Cholesterol : 128 mg/dL [< 100] | |
| Triglycerides : 185 mg/dL [< 150] | |
| VLDL : 37 mg/dL [< 30] | |
| """ | |
| def test_extracts_at_least_20_biomarkers(self): | |
| results = parse_biomarkers(self.DEMO_REPORT) | |
| assert len(results) >= 20, ( | |
| f"Expected β₯20 biomarkers, got {len(results)}: " | |
| f"{[r.name for r in results]}" | |
| ) | |
| def test_key_biomarkers_found(self): | |
| results = parse_biomarkers(self.DEMO_REPORT) | |
| names_lower = [r.name.lower() for r in results] | |
| required = [ | |
| "hemoglobin", "wbc", "platelet", "tsh", "vitamin d", | |
| "vitamin b12", "sgpt", "creatinine", "cholesterol", | |
| ] | |
| for req in required: | |
| found = any(req in n for n in names_lower) | |
| assert found, ( | |
| f"Required biomarker '{req}' not found. " | |
| f"Got: {[r.name for r in results]}" | |
| ) | |
| def test_hemoglobin_value_correct(self): | |
| results = parse_biomarkers(self.DEMO_REPORT) | |
| hb = next((r for r in results if "hemoglobin" in r.name.lower()), None) | |
| assert hb is not None | |
| assert parse_value(hb.value_raw) == pytest.approx(10.2) | |
| low, high = parse_range(hb.range_raw) | |
| assert low == pytest.approx(13.0) | |
| assert high == pytest.approx(17.0) | |
| def test_vitamin_d_range_parsed(self): | |
| results = parse_biomarkers(self.DEMO_REPORT) | |
| vd = next((r for r in results if "vitamin d" in r.name.lower()), None) | |
| assert vd is not None, "Vitamin D not found" | |
| low, high = parse_range(vd.range_raw) | |
| assert low == pytest.approx(30.0) | |
| assert high == pytest.approx(100.0) | |
| def test_lipid_less_than_range(self): | |
| results = parse_biomarkers(self.DEMO_REPORT) | |
| chol = next((r for r in results if "cholesterol" in r.name.lower() and "total" in r.name.lower()), None) | |
| if chol: | |
| low, high = parse_range(chol.range_raw) | |
| assert high == pytest.approx(200.0) | |
| assert low is None | |
| def test_no_header_lines_in_results(self): | |
| results = parse_biomarkers(self.DEMO_REPORT) | |
| names = [r.name.lower() for r in results] | |
| bad = ["test name", "parameter", "result", "reference", "thyrocare", "patient"] | |
| for b in bad: | |
| assert not any(b == n.strip() for n in names), ( | |
| f"Header/metadata '{b}' appeared in biomarker results" | |
| ) | |
| def test_no_duplicates(self): | |
| results = parse_biomarkers(self.DEMO_REPORT) | |
| names_lower = [r.name.lower() for r in results] | |
| duplicates = [n for n in names_lower if names_lower.count(n) > 1] | |
| assert not duplicates, f"Duplicate biomarkers found: {duplicates}" | |