From 3f8bf7d33dc55e8bd1068e090a4e5d5ead64c983 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 22:35:35 +0700 Subject: [PATCH 1/2] test: add missing coverage for lab_results dir loading and toxicity flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data/lab_results.py: 74% → 100% - load_lab_results_dir() was completely untested (lines 40-54 uncovered) - Added TestLoadLabResultsDir with 7 tests: empty directory, load all valid files, sort by assay_date, sort by result_id within same date, skip invalid JSON with warning, skip schema-invalid files with warning, ignore non-.json files - This function is the entry point for wet-lab result ingestion in the active-learning loop — covering it is scientifically important reports/batch_pack.py: 97% → 100% - Four _toxicity_flags() branches uncovered (charge_density, cysteine_fraction, longest_repeat_run, length_exceeds_35aa) - Cluster truncation line (>20 clusters → "more clusters" footer) uncovered - Added 6 new tests in TestToxicityHemolysisRiskReport and TestWriteBatchPackMarkdown to cover all remaining branches Overall coverage: 87.40% → 88.22% (+0.82 pp, 13 fewer uncovered lines) 1258 tests (was 1245) --- tests/test_batch_pack.py | 48 ++++++++++++++++++++++++++++++ tests/test_lab_results.py | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/tests/test_batch_pack.py b/tests/test_batch_pack.py index 9dd21ab2..50321117 100644 --- a/tests/test_batch_pack.py +++ b/tests/test_batch_pack.py @@ -213,6 +213,38 @@ def test_risk_thresholds_present(self): assert "risk_thresholds" in report assert "hydrophobic_fraction_high" in report["risk_thresholds"] + def test_high_charge_density_flagged(self): + cand = [_make_candidate("HIGH-CHG", "KKKKKKKKK")] + cand[0]["features"]["charge_density"] = 0.70 + report = toxicity_hemolysis_risk_report(cand) + assert "high_charge_density" in report["candidates"][0]["risk_flags"] + + def test_high_cysteine_fraction_flagged(self): + cand = [_make_candidate("HIGH-CYS", "CCCCCKKKK")] + cand[0]["features"]["cysteine_fraction"] = 0.55 + report = toxicity_hemolysis_risk_report(cand) + assert "high_cysteine_fraction" in report["candidates"][0]["risk_flags"] + + def test_long_repeat_run_flagged(self): + cand = [_make_candidate("REPEAT", "KKKKKKRRL")] + cand[0]["features"]["longest_repeat_run"] = 6 + report = toxicity_hemolysis_risk_report(cand) + assert "long_repeat_run" in report["candidates"][0]["risk_flags"] + + def test_length_exceeds_35aa_flagged(self): + long_seq = "KWKLFKKIGAVLKVLKWKLFKKIGAVLKVLKWKLFK" # 37 AA + cand = [_make_candidate("LONG", long_seq)] + cand[0]["features"]["length"] = 37 + report = toxicity_hemolysis_risk_report(cand) + assert "length_exceeds_35aa" in report["candidates"][0]["risk_flags"] + + def test_n_with_risk_flags_counts_correctly(self): + clean = _make_candidate("CLEAN", "KWKLFKKIGAVLKVL") + risky = _make_candidate("RISKY", "LLLLLLLLLLL") + risky["features"]["hydrophobic_fraction"] = 0.90 + report = toxicity_hemolysis_risk_report([clean, risky]) + assert report["n_with_risk_flags"] == 1 + class TestSynthesisFeasibilityReport: def test_report_type_field(self): @@ -382,3 +414,19 @@ def test_markdown_requires_human_review(self, ranked_jsonl, tmp_path): write_batch_pack_markdown(pack, md_path) content = md_path.read_text() assert "Human Review" in content or "human expert" in content.lower() + + def test_markdown_truncates_clusters_beyond_20(self, tmp_path): + _CANONICAL = list("ACDEFGHIKLMNPQRSTVWY") + rows = [] + for i, aa in enumerate(_CANONICAL): + rows.append(_make_candidate(f"CAND-{i:03d}", aa * 10, novelty=0.5 + i * 0.001)) + rows.append(_make_candidate("CAND-020", "KWKLFKKIGAVLKVLKWKLFK", novelty=0.6)) + jsonl_path = tmp_path / "big.jsonl" + with open(jsonl_path, "w") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + pack = generate_batch_pack(jsonl_path) + md_path = tmp_path / "big.md" + write_batch_pack_markdown(pack, md_path) + content = md_path.read_text() + assert "more clusters" in content diff --git a/tests/test_lab_results.py b/tests/test_lab_results.py index eb8f95d3..5e3ff071 100644 --- a/tests/test_lab_results.py +++ b/tests/test_lab_results.py @@ -5,16 +5,19 @@ - Invalid lab results are rejected with useful errors - summarise_lab_results produces correct aggregates - candidate_result_map groups correctly + - load_lab_results_dir loads all valid files, skips invalid, returns sorted """ from __future__ import annotations import json +import warnings import pytest from openamp_foundry.data.lab_results import ( candidate_result_map, load_lab_result, + load_lab_results_dir, summarise_lab_results, ) @@ -172,3 +175,61 @@ def test_unknown_candidate_not_in_map(self): def test_empty_results_gives_empty_map(self): assert candidate_result_map([]) == {} + + +class TestLoadLabResultsDir: + def test_empty_directory_returns_empty_list(self, tmp_path): + results = load_lab_results_dir(tmp_path) + assert results == [] + + def test_loads_all_valid_files(self, results_dir): + results = load_lab_results_dir(results_dir) + assert len(results) == 3 + + def test_sorted_by_assay_date(self, results_dir): + results = load_lab_results_dir(results_dir) + dates = [r["assay_date"] for r in results] + assert dates == sorted(dates) + + def test_sorted_by_result_id_within_same_date(self, tmp_path): + for i in [3, 1, 2]: + result = _valid_result( + result_id=f"RES-{i:03d}", + candidate_id=f"CAND-{i:03d}", + assay_date="2026-07-10", + ) + (tmp_path / f"RES-{i:03d}.json").write_text(json.dumps(result)) + results = load_lab_results_dir(tmp_path) + ids = [r["result_id"] for r in results] + assert ids == sorted(ids) + + def test_skips_invalid_json_file_with_warning(self, tmp_path): + valid = _valid_result(result_id="GOOD-001", candidate_id="CAND-A", assay_date="2026-07-01") + (tmp_path / "good.json").write_text(json.dumps(valid)) + (tmp_path / "bad.json").write_text("this is not valid json {{{") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + results = load_lab_results_dir(tmp_path) + assert len(results) == 1 + assert results[0]["result_id"] == "GOOD-001" + assert any("bad.json" in str(w.message) or "Skipped" in str(w.message) for w in caught) + + def test_skips_schema_invalid_file_with_warning(self, tmp_path): + valid = _valid_result(result_id="GOOD-001", candidate_id="CAND-A", assay_date="2026-07-01") + invalid = _valid_result() + del invalid["candidate_id"] + (tmp_path / "good.json").write_text(json.dumps(valid)) + (tmp_path / "invalid_schema.json").write_text(json.dumps(invalid)) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + results = load_lab_results_dir(tmp_path) + assert len(results) == 1 + assert any("Skipped" in str(w.message) for w in caught) + + def test_non_json_files_ignored(self, tmp_path): + valid = _valid_result(result_id="R001", candidate_id="C001", assay_date="2026-07-01") + (tmp_path / "result.json").write_text(json.dumps(valid)) + (tmp_path / "notes.txt").write_text("not a json file") + (tmp_path / "data.csv").write_text("col1,col2\n1,2") + results = load_lab_results_dir(tmp_path) + assert len(results) == 1 From 7dee465c55faad0ab530562aa663aab703727db1 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 22:41:44 +0700 Subject: [PATCH 2/2] test: fix 3 code-review findings in lab_results and batch_pack tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH: - test_sorted_by_assay_date: file creation order and date order were identical (RES-000→2026-07-01, etc.) so the sort was never exercised. Rewrote to use out-of-order dates (0→03, 1→01, 2→02) and assert the first result has the earliest date — a genuine regression test now. - test_skips_schema_invalid_file_with_warning: added identity assertion `results[0]["result_id"] == "GOOD-001"` matching the pattern from the sibling JSON-parse test; prevents a silent inversion where the wrong file is retained. LOW: - test_length_exceeds_35aa_flagged: sequence is 36 AA (not 37), corrected comment; removed redundant explicit features["length"] override since _make_candidate already derives length from len(sequence) --- tests/test_batch_pack.py | 3 +-- tests/test_lab_results.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_batch_pack.py b/tests/test_batch_pack.py index 50321117..632fc07e 100644 --- a/tests/test_batch_pack.py +++ b/tests/test_batch_pack.py @@ -232,9 +232,8 @@ def test_long_repeat_run_flagged(self): assert "long_repeat_run" in report["candidates"][0]["risk_flags"] def test_length_exceeds_35aa_flagged(self): - long_seq = "KWKLFKKIGAVLKVLKWKLFKKIGAVLKVLKWKLFK" # 37 AA + long_seq = "KWKLFKKIGAVLKVLKWKLFKKIGAVLKVLKWKLFK" # 36 AA (> 35 threshold) cand = [_make_candidate("LONG", long_seq)] - cand[0]["features"]["length"] = 37 report = toxicity_hemolysis_risk_report(cand) assert "length_exceeds_35aa" in report["candidates"][0]["risk_flags"] diff --git a/tests/test_lab_results.py b/tests/test_lab_results.py index 5e3ff071..3fd2d885 100644 --- a/tests/test_lab_results.py +++ b/tests/test_lab_results.py @@ -186,10 +186,19 @@ def test_loads_all_valid_files(self, results_dir): results = load_lab_results_dir(results_dir) assert len(results) == 3 - def test_sorted_by_assay_date(self, results_dir): - results = load_lab_results_dir(results_dir) + def test_sorted_by_assay_date(self, tmp_path): + date_map = {0: "2026-07-03", 1: "2026-07-01", 2: "2026-07-02"} + for i in range(3): + result = _valid_result( + result_id=f"RES-{i:03d}", + candidate_id=f"CAND-{i:03d}", + assay_date=date_map[i], + ) + (tmp_path / f"RES-{i:03d}.json").write_text(json.dumps(result)) + results = load_lab_results_dir(tmp_path) dates = [r["assay_date"] for r in results] assert dates == sorted(dates) + assert dates[0] == "2026-07-01" def test_sorted_by_result_id_within_same_date(self, tmp_path): for i in [3, 1, 2]: @@ -224,6 +233,7 @@ def test_skips_schema_invalid_file_with_warning(self, tmp_path): warnings.simplefilter("always") results = load_lab_results_dir(tmp_path) assert len(results) == 1 + assert results[0]["result_id"] == "GOOD-001" assert any("Skipped" in str(w.message) for w in caught) def test_non_json_files_ignored(self, tmp_path):