Spaces:
Sleeping
test(critic-fixes): address brittleness, over-mocking, and missing edge cases (#20)
Browse filesFindings from post-wave-4 critic review:
Missing edge cases (high):
- test_models.py: add TestGeocodeZip covering valid Nominatim response and
empty-results → ValueError (pytest-httpx)
- test_eligibility.py: document that _resolve_patient_value("sex") returns
found=False because PatientProfile has no sex field
Missing edge cases (medium):
- test_trials_api.py: add test_location_at_zero_lat_is_silently_dropped
documenting that lat=0.0 is falsy and causes the site to be skipped
- test_intake.py: add test_future_date_returns_negative documenting that
_months_from_date does not clamp future dates to zero
Brittleness fixes (medium):
- test_eligibility.py: test_empty_eligibility_text_returns_pass now uses
assert_not_called() instead of an unconsumed side_effect list
- test_research.py: test_two_search_calls_before_end_turn now asserts the
return value instead of only call_count
- test_trials_api.py: test_geo_filter_formatted now asserts structure
instead of exact float-to-string representation
- test_research.py: remove bare call_count assertion from
test_one_search_call_then_end_turn (assert_called_once on ms/mf/mb is enough)
Over-mocking (medium):
- test_research.py: add test_search_results_serialized_into_tool_result to
verify flatten output flows through bulk_parse and into tool-result JSON
Duplicated fixtures (low):
- test_models.py: extract minimal_patient factory fixture; replace six
inline PatientProfile constructions with minimal_patient(**overrides)
Result: 161 passed, 0 failed — 96% overall coverage (up from 95%)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- tests/agents/test_eligibility.py +11 -9
- tests/agents/test_intake.py +9 -0
- tests/agents/test_research.py +27 -2
- tests/test_models.py +42 -42
- tests/test_trials_api.py +22 -1
|
@@ -218,6 +218,14 @@ class TestResolvePatientValue:
|
|
| 218 |
val, found = _resolve_patient_value("ecog_status", als_patient, platform)
|
| 219 |
assert found is False
|
| 220 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
# ---------------------------------------------------------------------------
|
| 223 |
# _compute_overall
|
|
@@ -436,18 +444,12 @@ class TestRunEligibilityCheck:
|
|
| 436 |
assert report.overall_verdict == CriterionVerdict.FAIL
|
| 437 |
|
| 438 |
def test_empty_eligibility_text_returns_pass(self, mock_client, als_patient):
|
| 439 |
-
parse_resp = make_message(
|
| 440 |
-
content=[make_tool_use_block("parse_criteria", {"criteria": []})],
|
| 441 |
-
stop_reason="tool_use",
|
| 442 |
-
)
|
| 443 |
-
assess_resp = make_message(
|
| 444 |
-
content=[make_tool_use_block("assess_eligibility", {"assessments": []})],
|
| 445 |
-
stop_reason="tool_use",
|
| 446 |
-
)
|
| 447 |
-
mock_client.messages.create.side_effect = [parse_resp, assess_resp]
|
| 448 |
trial = {"nct_id": "NCT00000002", "eligibility": ""}
|
| 449 |
report = run_eligibility_check(mock_client, trial, als_patient)
|
| 450 |
assert report.overall_verdict == CriterionVerdict.PASS
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
def test_parse_called_once_assess_called_once(self, mock_client, als_patient):
|
| 453 |
# ecog_status is a platform key; without platform_data it goes to needs_llm,
|
|
|
|
| 218 |
val, found = _resolve_patient_value("ecog_status", als_patient, platform)
|
| 219 |
assert found is False
|
| 220 |
|
| 221 |
+
def test_sex_key_not_found_because_patient_profile_has_no_sex_field(self, als_patient):
|
| 222 |
+
# criterion_keys.json maps "sex" → "patient.sex" but PatientProfile has no
|
| 223 |
+
# sex attribute. This documents the current behavior so a future schema
|
| 224 |
+
# addition is caught immediately.
|
| 225 |
+
val, found = _resolve_patient_value("sex", als_patient, None)
|
| 226 |
+
assert found is False
|
| 227 |
+
assert val is None
|
| 228 |
+
|
| 229 |
|
| 230 |
# ---------------------------------------------------------------------------
|
| 231 |
# _compute_overall
|
|
|
|
| 444 |
assert report.overall_verdict == CriterionVerdict.FAIL
|
| 445 |
|
| 446 |
def test_empty_eligibility_text_returns_pass(self, mock_client, als_patient):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
trial = {"nct_id": "NCT00000002", "eligibility": ""}
|
| 448 |
report = run_eligibility_check(mock_client, trial, als_patient)
|
| 449 |
assert report.overall_verdict == CriterionVerdict.PASS
|
| 450 |
+
# _parse_criteria short-circuits on empty text; _assess_llm short-circuits
|
| 451 |
+
# on empty criteria list — neither should make an API call
|
| 452 |
+
mock_client.messages.create.assert_not_called()
|
| 453 |
|
| 454 |
def test_parse_called_once_assess_called_once(self, mock_client, als_patient):
|
| 455 |
# ecog_status is a platform key; without platform_data it goes to needs_llm,
|
|
@@ -46,6 +46,15 @@ class TestMonthsFromDate:
|
|
| 46 |
def test_empty_string_returns_zero(self):
|
| 47 |
assert _months_from_date("") == 0
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# ---------------------------------------------------------------------------
|
| 51 |
# _resolve_months
|
|
|
|
| 46 |
def test_empty_string_returns_zero(self):
|
| 47 |
assert _months_from_date("") == 0
|
| 48 |
|
| 49 |
+
@freeze_time("2026-05-01")
|
| 50 |
+
def test_future_date_returns_negative(self):
|
| 51 |
+
# The function does not clamp — a future onset_date from the LLM produces
|
| 52 |
+
# a negative onset_months, which can cause eligibility criteria like
|
| 53 |
+
# "onset_months >= 6" to pass incorrectly. Documented here so a clamping
|
| 54 |
+
# fix is validated when added.
|
| 55 |
+
result = _months_from_date("2030-01")
|
| 56 |
+
assert result < 0
|
| 57 |
+
|
| 58 |
|
| 59 |
# ---------------------------------------------------------------------------
|
| 60 |
# _resolve_months
|
|
@@ -65,7 +65,6 @@ class TestRunResearchAgent:
|
|
| 65 |
result = run_research_agent(mock_client, als_patient)
|
| 66 |
|
| 67 |
assert result == "Found 3 trials."
|
| 68 |
-
assert mock_client.messages.create.call_count == 2
|
| 69 |
ms.assert_called_once()
|
| 70 |
mf.assert_called_once()
|
| 71 |
mb.assert_called_once()
|
|
@@ -81,9 +80,35 @@ class TestRunResearchAgent:
|
|
| 81 |
with patch(PATCH_SEARCH, return_value=[]), \
|
| 82 |
patch(PATCH_FLATTEN, return_value=[]), \
|
| 83 |
patch(PATCH_BULK, return_value=[]):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
run_research_agent(mock_client, als_patient)
|
| 85 |
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
def test_search_api_error_passes_error_content(self, mock_client, als_patient):
|
| 89 |
search_msg = make_message(content=[_search_block()], stop_reason="tool_use")
|
|
|
|
| 65 |
result = run_research_agent(mock_client, als_patient)
|
| 66 |
|
| 67 |
assert result == "Found 3 trials."
|
|
|
|
| 68 |
ms.assert_called_once()
|
| 69 |
mf.assert_called_once()
|
| 70 |
mb.assert_called_once()
|
|
|
|
| 80 |
with patch(PATCH_SEARCH, return_value=[]), \
|
| 81 |
patch(PATCH_FLATTEN, return_value=[]), \
|
| 82 |
patch(PATCH_BULK, return_value=[]):
|
| 83 |
+
result = run_research_agent(mock_client, als_patient)
|
| 84 |
+
|
| 85 |
+
assert result == "Done."
|
| 86 |
+
|
| 87 |
+
def test_search_results_serialized_into_tool_result(self, mock_client, als_patient):
|
| 88 |
+
"""Verify flatten output flows through bulk_parse and into the tool-result message."""
|
| 89 |
+
import json as _json
|
| 90 |
+
fake_trial = {"nct_id": "NCT00000001", "title": "ALS Trial", "parsed_criteria": []}
|
| 91 |
+
search_msg = make_message(content=[_search_block()], stop_reason="tool_use")
|
| 92 |
+
final_msg = make_message(
|
| 93 |
+
content=[make_text_block("Here are results.")], stop_reason="end_turn"
|
| 94 |
+
)
|
| 95 |
+
mock_client.messages.create.side_effect = [search_msg, final_msg]
|
| 96 |
+
|
| 97 |
+
with patch(PATCH_SEARCH, return_value=[{}]), \
|
| 98 |
+
patch(PATCH_FLATTEN, return_value=[fake_trial]), \
|
| 99 |
+
patch(PATCH_BULK, return_value=[fake_trial]):
|
| 100 |
run_research_agent(mock_client, als_patient)
|
| 101 |
|
| 102 |
+
second_call_msgs = mock_client.messages.create.call_args_list[1][1]["messages"]
|
| 103 |
+
user_msgs = [m for m in second_call_msgs if m["role"] == "user"]
|
| 104 |
+
tool_blocks = [
|
| 105 |
+
b for m in user_msgs
|
| 106 |
+
for b in (m["content"] if isinstance(m["content"], list) else [])
|
| 107 |
+
if isinstance(b, dict) and b.get("type") == "tool_result"
|
| 108 |
+
]
|
| 109 |
+
assert tool_blocks, "No tool_result block found in second call"
|
| 110 |
+
content = _json.loads(tool_blocks[0]["content"])
|
| 111 |
+
assert content[0]["nct_id"] == "NCT00000001"
|
| 112 |
|
| 113 |
def test_search_api_error_passes_error_content(self, mock_client, als_patient):
|
| 114 |
search_msg = make_message(content=[_search_block()], stop_reason="tool_use")
|
|
@@ -5,7 +5,17 @@ import math
|
|
| 5 |
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
-
from models import PatientProfile, haversine_miles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
# ---------------------------------------------------------------------------
|
|
@@ -64,57 +74,47 @@ class TestPatientProfileSummary:
|
|
| 64 |
assert "alsfrs_r" in text
|
| 65 |
assert "38" in text
|
| 66 |
|
| 67 |
-
def test_summary_no_benchmarks(self):
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
)
|
| 72 |
-
assert "Benchmarks" not in patient.summary()
|
| 73 |
-
|
| 74 |
-
def test_summary_phase_labels(self):
|
| 75 |
-
patient = PatientProfile(
|
| 76 |
-
disease="ALS", age=45, onset_months=6,
|
| 77 |
-
zip_code="10001", lat=40.75, lon=-73.99,
|
| 78 |
-
phases=["2", "3", "0", "na"],
|
| 79 |
-
)
|
| 80 |
-
text = patient.summary()
|
| 81 |
assert "Phase 2" in text
|
| 82 |
assert "Phase 3" in text
|
| 83 |
assert "Early Phase 1" in text
|
| 84 |
assert "Not Applicable" in text
|
| 85 |
|
| 86 |
-
def test_summary_study_type_defaults(self):
|
| 87 |
-
|
| 88 |
-
disease="ALS", age=45, onset_months=6,
|
| 89 |
-
zip_code="10001", lat=40.75, lon=-73.99,
|
| 90 |
-
)
|
| 91 |
-
text = patient.summary()
|
| 92 |
assert "Clinical trials" in text
|
| 93 |
assert "Observational" not in text
|
| 94 |
assert "EAP" not in text
|
| 95 |
|
| 96 |
-
def test_summary_include_observational(self):
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
)
|
| 102 |
-
assert "Observational" in patient.summary()
|
| 103 |
-
|
| 104 |
-
def test_summary_include_eap(self):
|
| 105 |
-
patient = PatientProfile(
|
| 106 |
-
disease="ALS", age=45, onset_months=6,
|
| 107 |
-
zip_code="10001", lat=40.75, lon=-73.99,
|
| 108 |
-
include_eap=True,
|
| 109 |
-
)
|
| 110 |
-
assert "EAP" in patient.summary()
|
| 111 |
|
| 112 |
def test_summary_returns_string(self, als_patient):
|
| 113 |
assert isinstance(als_patient.summary(), str)
|
| 114 |
|
| 115 |
-
def test_summary_no_phases(self):
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
+
from models import PatientProfile, geocode_zip, haversine_miles
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def minimal_patient(**kwargs):
|
| 13 |
+
"""Minimal PatientProfile factory — callers only specify fields that vary."""
|
| 14 |
+
def _make(**overrides):
|
| 15 |
+
defaults = dict(disease="ALS", age=45, onset_months=6, zip_code="10001", lat=40.75, lon=-73.99)
|
| 16 |
+
defaults.update(overrides)
|
| 17 |
+
return PatientProfile(**defaults)
|
| 18 |
+
return _make
|
| 19 |
|
| 20 |
|
| 21 |
# ---------------------------------------------------------------------------
|
|
|
|
| 74 |
assert "alsfrs_r" in text
|
| 75 |
assert "38" in text
|
| 76 |
|
| 77 |
+
def test_summary_no_benchmarks(self, minimal_patient):
|
| 78 |
+
assert "Benchmarks" not in minimal_patient().summary()
|
| 79 |
+
|
| 80 |
+
def test_summary_phase_labels(self, minimal_patient):
|
| 81 |
+
text = minimal_patient(phases=["2", "3", "0", "na"]).summary()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
assert "Phase 2" in text
|
| 83 |
assert "Phase 3" in text
|
| 84 |
assert "Early Phase 1" in text
|
| 85 |
assert "Not Applicable" in text
|
| 86 |
|
| 87 |
+
def test_summary_study_type_defaults(self, minimal_patient):
|
| 88 |
+
text = minimal_patient().summary()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
assert "Clinical trials" in text
|
| 90 |
assert "Observational" not in text
|
| 91 |
assert "EAP" not in text
|
| 92 |
|
| 93 |
+
def test_summary_include_observational(self, minimal_patient):
|
| 94 |
+
assert "Observational" in minimal_patient(include_observational=True).summary()
|
| 95 |
+
|
| 96 |
+
def test_summary_include_eap(self, minimal_patient):
|
| 97 |
+
assert "EAP" in minimal_patient(include_eap=True).summary()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
def test_summary_returns_string(self, als_patient):
|
| 100 |
assert isinstance(als_patient.summary(), str)
|
| 101 |
|
| 102 |
+
def test_summary_no_phases(self, minimal_patient):
|
| 103 |
+
assert "Phases:" not in minimal_patient().summary()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
# geocode_zip — httpx mocked
|
| 108 |
+
# ---------------------------------------------------------------------------
|
| 109 |
+
|
| 110 |
+
class TestGeocodeZip:
|
| 111 |
+
def test_returns_lat_lon_from_response(self, httpx_mock):
|
| 112 |
+
httpx_mock.add_response(json=[{"lat": "42.3370", "lon": "-71.1061"}])
|
| 113 |
+
lat, lon = geocode_zip("02115", "US")
|
| 114 |
+
assert lat == pytest.approx(42.3370)
|
| 115 |
+
assert lon == pytest.approx(-71.1061)
|
| 116 |
+
|
| 117 |
+
def test_empty_results_raises_value_error(self, httpx_mock):
|
| 118 |
+
httpx_mock.add_response(json=[])
|
| 119 |
+
with pytest.raises(ValueError, match="Cannot geocode"):
|
| 120 |
+
geocode_zip("00000", "US")
|
|
@@ -146,6 +146,23 @@ class TestFlattenAndRank:
|
|
| 146 |
result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
|
| 147 |
assert result[0]["nearest_sites"][0]["phone"] == "617-555-0100"
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
def test_required_keys_present(self):
|
| 150 |
studies = [make_study()]
|
| 151 |
result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
|
|
@@ -218,7 +235,11 @@ class TestSearchTrialsApiAggFilters:
|
|
| 218 |
|
| 219 |
def test_geo_filter_formatted(self, httpx_mock):
|
| 220 |
params = self._get_params(httpx_mock, radius_miles=50)
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
|
| 223 |
def test_condition_passed_through(self, httpx_mock):
|
| 224 |
params = self._get_params(httpx_mock)
|
|
|
|
| 146 |
result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
|
| 147 |
assert result[0]["nearest_sites"][0]["phone"] == "617-555-0100"
|
| 148 |
|
| 149 |
+
def test_location_at_zero_lat_is_silently_dropped(self):
|
| 150 |
+
# lat=0.0 is falsy — the `if geo.get("lat") and geo.get("lon")` guard
|
| 151 |
+
# skips it, so a site on the equator is treated as if it has no coordinates.
|
| 152 |
+
# This test documents the current (surprising) behavior.
|
| 153 |
+
loc = {
|
| 154 |
+
"facility": "Equator Clinic",
|
| 155 |
+
"city": "Quito",
|
| 156 |
+
"state": "Ecuador",
|
| 157 |
+
"geoPoint": {"lat": 0.0, "lon": -78.5},
|
| 158 |
+
"contacts": [],
|
| 159 |
+
}
|
| 160 |
+
studies = [make_study(locations=[loc])]
|
| 161 |
+
result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
|
| 162 |
+
# Because lat=0.0 is falsy the site is skipped — closest_site_miles is None
|
| 163 |
+
assert result[0]["closest_site_miles"] is None
|
| 164 |
+
assert result[0]["nearest_sites"] == []
|
| 165 |
+
|
| 166 |
def test_required_keys_present(self):
|
| 167 |
studies = [make_study()]
|
| 168 |
result = _flatten_and_rank(studies, self.PATIENT_LAT, self.PATIENT_LON)
|
|
|
|
| 235 |
|
| 236 |
def test_geo_filter_formatted(self, httpx_mock):
|
| 237 |
params = self._get_params(httpx_mock, radius_miles=50)
|
| 238 |
+
geo = params["filter.geo"]
|
| 239 |
+
# Assert structure without depending on float-to-string representation
|
| 240 |
+
assert geo.startswith("distance(")
|
| 241 |
+
assert "50mi)" in geo
|
| 242 |
+
assert "42.33" in geo
|
| 243 |
|
| 244 |
def test_condition_passed_through(self, httpx_mock):
|
| 245 |
params = self._get_params(httpx_mock)
|