KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
3d5e588
·
unverified ·
1 Parent(s): a6f4696

test(critic-fixes): address brittleness, over-mocking, and missing edge cases (#20)

Browse files

Findings 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 CHANGED
@@ -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,
tests/agents/test_intake.py CHANGED
@@ -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
tests/agents/test_research.py CHANGED
@@ -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
- assert mock_client.messages.create.call_count == 3
 
 
 
 
 
 
 
 
 
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")
tests/test_models.py CHANGED
@@ -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
- patient = PatientProfile(
69
- disease="ALS", age=45, onset_months=6,
70
- zip_code="10001", lat=40.75, lon=-73.99,
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
- patient = PatientProfile(
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
- patient = PatientProfile(
98
- disease="ALS", age=45, onset_months=6,
99
- zip_code="10001", lat=40.75, lon=-73.99,
100
- include_observational=True,
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
- patient = PatientProfile(
117
- disease="ALS", age=45, onset_months=6,
118
- zip_code="10001", lat=40.75, lon=-73.99,
119
- )
120
- assert "Phases:" not in patient.summary()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
tests/test_trials_api.py CHANGED
@@ -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
- assert params["filter.geo"] == "distance(42.33,-71.1,50mi)"
 
 
 
 
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)