research-tool-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. app/backend/_bootstrap.py +15 -0
  2. app/backend/exports/verification_script.py +19 -0
  3. app/backend/main.py +59 -0
  4. app/backend/routers/__init__.py +1 -0
  5. app/backend/routers/execution.py +478 -0
  6. app/backend/routers/ingestion.py +233 -0
  7. app/backend/routers/planning.py +265 -0
  8. app/backend/routers/reporting.py +531 -0
  9. app/backend/state.py +44 -0
  10. core/__init__.py +0 -0
  11. core/cli/__init__.py +0 -0
  12. core/cli/main.py +1705 -0
  13. core/database.py +62 -0
  14. core/ingestion/__init__.py +0 -0
  15. core/ingestion/csv_loader.py +191 -0
  16. core/ingestion/variable_classifier.py +171 -0
  17. core/masking/__init__.py +0 -0
  18. core/masking/gate.py +128 -0
  19. core/models.py +138 -0
  20. core/planning/__init__.py +0 -0
  21. core/planning/diagnostics.py +89 -0
  22. core/planning/lock.py +232 -0
  23. core/planning/study_plan.py +73 -0
  24. core/planning/test_selector.py +518 -0
  25. core/provenance/__init__.py +0 -0
  26. core/provenance/hashing.py +38 -0
  27. core/provenance/tracker.py +105 -0
  28. core/reporting/__init__.py +62 -0
  29. core/reporting/appendix.py +58 -0
  30. core/reporting/bundle.py +378 -0
  31. core/reporting/excel_export.py +683 -0
  32. core/reporting/flowchart/__init__.py +20 -0
  33. core/reporting/flowchart/flowchart.py +511 -0
  34. core/reporting/forensics.py +592 -0
  35. core/reporting/forest_plot.py +614 -0
  36. core/reporting/lineage.py +562 -0
  37. core/reporting/manuscript_draft.py +726 -0
  38. core/reporting/plots.py +568 -0
  39. core/reporting/strobe_checklist.py +460 -0
  40. core/stats/__init__.py +0 -0
  41. core/stats/descriptive.py +104 -0
  42. core/stats/inferential.py +540 -0
  43. core/stats/multiple_comparisons.py +62 -0
  44. core/stats/post_hoc.py +62 -0
  45. exports/verification_script.py +19 -0
  46. research_tool_cli-0.1.0.dist-info/METADATA +16 -0
  47. research_tool_cli-0.1.0.dist-info/RECORD +93 -0
  48. research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
  49. research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
  52. tests/__init__.py +0 -0
  53. tests/conftest.py +45 -0
  54. tests/test_amendments.py +296 -0
  55. tests/test_analyze_batch_robustness.py +162 -0
  56. tests/test_app_statistical_reporting.py +383 -0
  57. tests/test_benchmark_21.py +556 -0
  58. tests/test_bundle.py +277 -0
  59. tests/test_cox_ph_plan.py +498 -0
  60. tests/test_csv_loader.py +368 -0
  61. tests/test_end_to_end.py +302 -0
  62. tests/test_excel_export.py +164 -0
  63. tests/test_flowchart.py +244 -0
  64. tests/test_forensics.py +305 -0
  65. tests/test_forest_plot.py +374 -0
  66. tests/test_from_json.py +176 -0
  67. tests/test_latest_plan_version.py +164 -0
  68. tests/test_lineage.py +329 -0
  69. tests/test_lock_immutability.py +133 -0
  70. tests/test_m1_fk_violation.py +85 -0
  71. tests/test_m2_data_hash_consistency.py +40 -0
  72. tests/test_m3_correction_timing.py +59 -0
  73. tests/test_m4_dedup_field.py +59 -0
  74. tests/test_m5_excel_hash_scope.py +45 -0
  75. tests/test_m6_fisher_exact_naming.py +44 -0
  76. tests/test_m7_duplicate_study_plan.py +55 -0
  77. tests/test_m8_filter_superseded.py +58 -0
  78. tests/test_m9_table1_groupby.py +56 -0
  79. tests/test_manuscript_draft.py +289 -0
  80. tests/test_masking_gate.py +196 -0
  81. tests/test_masking_migration.py +111 -0
  82. tests/test_multiple_comparisons.py +56 -0
  83. tests/test_plan_validation.py +289 -0
  84. tests/test_plots.py +394 -0
  85. tests/test_post_hoc_tagging.py +49 -0
  86. tests/test_posthoc_analyze.py +203 -0
  87. tests/test_provenance_tracker.py +110 -0
  88. tests/test_roadmap_features.py +148 -0
  89. tests/test_stats_descriptive.py +57 -0
  90. tests/test_stats_inferential.py +380 -0
  91. tests/test_strobe_checklist.py +172 -0
  92. tests/test_test_selector.py +350 -0
  93. tests/test_variable_classifier.py +124 -0
@@ -0,0 +1,289 @@
1
+ """Tests for plan-time variable validation in cmd_plan()."""
2
+
3
+ import argparse
4
+ import shutil
5
+
6
+ import pytest
7
+
8
+ from core.cli.main import cmd_plan
9
+ from core.database import get_connection, init_db, DATA_ROOT
10
+
11
+
12
+ STUDY_ID = "test_plan_val"
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def _setup():
17
+ if (DATA_ROOT / STUDY_ID).exists():
18
+ shutil.rmtree(DATA_ROOT / STUDY_ID)
19
+ (DATA_ROOT / STUDY_ID).mkdir(parents=True, exist_ok=True)
20
+ conn = get_connection(STUDY_ID)
21
+ init_db(conn)
22
+ conn.execute(
23
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type) VALUES (?, ?, ?, ?, ?)",
24
+ (STUDY_ID, "Plan Validation", "2025-01-01", str(DATA_ROOT / STUDY_ID), "cohort"),
25
+ )
26
+ conn.commit()
27
+ conn.close()
28
+ yield
29
+ p = DATA_ROOT / STUDY_ID
30
+ if p.exists():
31
+ shutil.rmtree(p)
32
+
33
+
34
+ def _plan_args(outcome_var_ids="1", test=("age:t_test:",), covariates="",
35
+ study_type="cohort", matching_criteria="", comparison="Test comparison"):
36
+ """Helper to build an argparse Namespace as cmd_plan expects."""
37
+ ns = argparse.Namespace(
38
+ study_id=STUDY_ID,
39
+ outcome_var_ids=outcome_var_ids,
40
+ tests=test,
41
+ covariates=covariates,
42
+ study_type=study_type,
43
+ comparison=comparison,
44
+ )
45
+ if matching_criteria:
46
+ ns.matching_criteria = matching_criteria
47
+ return ns
48
+
49
+
50
+ def test_plan_rejects_no_variables():
51
+ """No classified variables → error, no provisional plan saved."""
52
+ prov_path = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
53
+ with pytest.raises(SystemExit):
54
+ cmd_plan(_plan_args())
55
+ assert not prov_path.exists()
56
+
57
+
58
+ def test_plan_rejects_unknown_outcome_id(capsys):
59
+ """outcome-var-ids references a non-existent variable ID."""
60
+ # Insert a variable with id=1 only
61
+ conn = get_connection(STUDY_ID)
62
+ conn.execute(
63
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
64
+ "VALUES (1, ?, 'age', 'baseline', 'continuous') ON CONFLICT(id) DO UPDATE SET role='baseline'",
65
+ (STUDY_ID,),
66
+ )
67
+ conn.commit()
68
+ conn.close()
69
+
70
+ with pytest.raises(SystemExit):
71
+ cmd_plan(_plan_args(outcome_var_ids="2")) # id=2 doesn't exist
72
+
73
+ stderr = capsys.readouterr().err
74
+ assert "not found" in stderr
75
+ assert "2" in stderr
76
+
77
+
78
+ def test_plan_rejects_baseline_as_outcome(capsys):
79
+ """outcome-var-ids references a baseline variable → error."""
80
+ conn = get_connection(STUDY_ID)
81
+ conn.execute(
82
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
83
+ "VALUES (1, ?, 'age', 'baseline', 'continuous') ON CONFLICT(id) DO UPDATE SET role='baseline'",
84
+ (STUDY_ID,),
85
+ )
86
+ conn.commit()
87
+ conn.close()
88
+
89
+ with pytest.raises(SystemExit):
90
+ cmd_plan(_plan_args(outcome_var_ids="1"))
91
+
92
+ stderr = capsys.readouterr().err
93
+ assert "baseline" in stderr
94
+ assert "not 'outcome'" in stderr or "not outcome" in stderr
95
+
96
+
97
+ def test_plan_rejects_unknown_covariate_id(capsys):
98
+ """covariates references a non-existent variable ID."""
99
+ conn = get_connection(STUDY_ID)
100
+ conn.execute(
101
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
102
+ "VALUES (1, ?, 'response', 'outcome', 'categorical') ON CONFLICT(id) DO UPDATE SET role='outcome'",
103
+ (STUDY_ID,),
104
+ )
105
+ conn.commit()
106
+ conn.close()
107
+
108
+ with pytest.raises(SystemExit):
109
+ cmd_plan(_plan_args(covariates="99"))
110
+
111
+ stderr = capsys.readouterr().err
112
+ assert "99" in stderr
113
+ assert "not found" in stderr
114
+
115
+
116
+ def test_plan_accepts_valid_variables(capsys):
117
+ """Valid outcome and covariate IDs → plan proceeds normally."""
118
+ conn = get_connection(STUDY_ID)
119
+ conn.execute(
120
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
121
+ "VALUES (1, ?, 'response', 'outcome', 'categorical') ON CONFLICT(id) DO UPDATE SET role='outcome'",
122
+ (STUDY_ID,),
123
+ )
124
+ conn.execute(
125
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
126
+ "VALUES (2, ?, 'age', 'baseline', 'continuous') ON CONFLICT(id) DO UPDATE SET role='baseline'",
127
+ (STUDY_ID,),
128
+ )
129
+ conn.commit()
130
+ conn.close()
131
+
132
+ # Should NOT exit
133
+ cmd_plan(_plan_args(outcome_var_ids="1", covariates="2"))
134
+ prov_path = DATA_ROOT / STUDY_ID / "study_plan.provisional.json"
135
+ assert prov_path.exists()
136
+
137
+
138
+ def test_matching_criterion_overlap_warns(capsys):
139
+ """Matching on the comparison variable should produce an advisory warning."""
140
+ conn = get_connection(STUDY_ID)
141
+ conn.execute(
142
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
143
+ "VALUES (1, ?, 'response', 'outcome', 'categorical') ON CONFLICT(id) DO UPDATE SET role='outcome'",
144
+ (STUDY_ID,),
145
+ )
146
+ conn.execute(
147
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
148
+ "VALUES (5, ?, 'treatment_arm', 'baseline', 'categorical') ON CONFLICT(id) DO UPDATE SET role='baseline'",
149
+ (STUDY_ID,),
150
+ )
151
+ conn.commit()
152
+ conn.close()
153
+
154
+ cmd_plan(_plan_args(
155
+ outcome_var_ids="1",
156
+ study_type="case_control",
157
+ comparison="Response by treatment arm",
158
+ matching_criteria="5",
159
+ ))
160
+
161
+ stderr = capsys.readouterr().err
162
+ assert "Warning: variable 'treatment_arm' is declared as both" in stderr
163
+ assert "Matching on the comparison variable can obscure" in stderr
164
+
165
+
166
+ def test_matching_criterion_no_overlap_no_warning(capsys):
167
+ """Matching on a different variable should not trigger the overlap warning."""
168
+ conn = get_connection(STUDY_ID)
169
+ conn.execute(
170
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
171
+ "VALUES (1, ?, 'response', 'outcome', 'categorical') ON CONFLICT(id) DO UPDATE SET role='outcome'",
172
+ (STUDY_ID,),
173
+ )
174
+ conn.execute(
175
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
176
+ "VALUES (5, ?, 'treatment_arm', 'baseline', 'categorical') ON CONFLICT(id) DO UPDATE SET role='baseline'",
177
+ (STUDY_ID,),
178
+ )
179
+ conn.execute(
180
+ "INSERT INTO variables (id, study_id, column_name, role, data_type) "
181
+ "VALUES (3, ?, 'age', 'baseline', 'continuous') ON CONFLICT(id) DO UPDATE SET role='baseline'",
182
+ (STUDY_ID,),
183
+ )
184
+ conn.commit()
185
+ conn.close()
186
+
187
+ cmd_plan(_plan_args(
188
+ outcome_var_ids="1",
189
+ study_type="case_control",
190
+ comparison="Response by treatment arm",
191
+ matching_criteria="3", # matching on age, not treatment_arm
192
+ ))
193
+
194
+ stderr = capsys.readouterr().err
195
+ assert "declared as both a matching criterion" not in stderr
196
+
197
+
198
+ def _setup_for_lock_test(with_duplicates: bool):
199
+ """Set up a study with variables, a provisional plan, and optionally duplicates."""
200
+ conn = get_connection(STUDY_ID)
201
+ raw = f"raw_{STUDY_ID}"
202
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, patient_id TEXT, age TEXT, response TEXT)")
203
+ conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
204
+ conn.execute("INSERT INTO variables (id, study_id, column_name, role, data_type) VALUES (1, ?, 'response', 'outcome', 'categorical') ON CONFLICT(id) DO UPDATE SET role='outcome'",
205
+ (STUDY_ID,))
206
+ conn.execute("INSERT INTO variables (id, study_id, column_name, role, data_type) VALUES (2, ?, 'age', 'baseline', 'continuous') ON CONFLICT(id) DO UPDATE SET role='baseline'",
207
+ (STUDY_ID,))
208
+ conn.execute(f"DELETE FROM {raw}")
209
+ conn.execute(f"INSERT INTO {raw} (patient_id, age, response) VALUES ('P001', '65', 'CR')")
210
+ conn.execute(f"INSERT INTO {raw} (patient_id, age, response) VALUES ('P002', '70', 'PR')")
211
+ if with_duplicates:
212
+ conn.execute(f"INSERT INTO {raw} (patient_id, age, response) VALUES ('P001', '55', 'SD')")
213
+ conn.commit()
214
+ conn.close()
215
+
216
+ # Seal outcomes so lock_plan's is_masked check passes
217
+ from core.masking.gate import seal_outcomes
218
+ seal_outcomes(STUDY_ID)
219
+
220
+ # Write a provisional plan
221
+ import json
222
+ plan = {
223
+ "study_id": STUDY_ID, "version": 1,
224
+ "study_type": "cohort", "primary_comparison": "test",
225
+ "planned_tests": [{"variable_name": "response", "test_name": "chi_square"}],
226
+ "primary_outcome_variable_ids": [1],
227
+ "covariates": [],
228
+ "matching_criteria": [],
229
+ "warnings": {},
230
+ }
231
+ (DATA_ROOT / STUDY_ID / "study_plan.provisional.json").write_text(json.dumps(plan))
232
+
233
+
234
+ def test_lock_rejects_duplicate_ids():
235
+ """Locking a study with duplicate patient IDs must fail."""
236
+ _setup_for_lock_test(with_duplicates=True)
237
+ with pytest.raises(SystemExit):
238
+ from core.cli.main import cmd_lock
239
+ import argparse
240
+ ns = argparse.Namespace(study_id=STUDY_ID, allow_duplicate_ids=False)
241
+ cmd_lock(ns)
242
+
243
+
244
+ def test_lock_allow_duplicate_ids_override():
245
+ """--allow-duplicate-ids must bypass the duplicate check."""
246
+ _setup_for_lock_test(with_duplicates=True)
247
+ from core.cli.main import cmd_lock
248
+ import argparse
249
+ ns = argparse.Namespace(study_id=STUDY_ID, allow_duplicate_ids=True)
250
+ cmd_lock(ns)
251
+ # Should have produced a locked file
252
+ locked = list(DATA_ROOT.glob(f"{STUDY_ID}/study_plan.v*.locked.json"))
253
+ assert len(locked) >= 1
254
+
255
+
256
+ def test_lock_no_duplicates_normal():
257
+ """Locking a study with no duplicates should proceed normally."""
258
+ _setup_for_lock_test(with_duplicates=False)
259
+ from core.cli.main import cmd_lock
260
+ import argparse
261
+ ns = argparse.Namespace(study_id=STUDY_ID, allow_duplicate_ids=False)
262
+ cmd_lock(ns)
263
+ locked = list(DATA_ROOT.glob(f"{STUDY_ID}/study_plan.v*.locked.json"))
264
+ assert len(locked) >= 1
265
+
266
+
267
+ def test_lock_strict_ids_blocks_duplicates(capsys):
268
+ """--strict-ids must abort with clear error message when duplicates exist."""
269
+ _setup_for_lock_test(with_duplicates=True)
270
+ from core.cli.main import cmd_lock
271
+ import argparse
272
+ ns = argparse.Namespace(study_id=STUDY_ID, strict_ids=True, allow_duplicate_ids=False)
273
+ with pytest.raises(SystemExit):
274
+ cmd_lock(ns)
275
+ err = capsys.readouterr().err
276
+ assert "duplicate patient identifiers found" in err
277
+ assert "P001" in err
278
+
279
+
280
+ def test_lock_strict_ids_passes_when_unique():
281
+ """--strict-ids allows locking when patient IDs are unique."""
282
+ _setup_for_lock_test(with_duplicates=False)
283
+ from core.cli.main import cmd_lock
284
+ import argparse
285
+ ns = argparse.Namespace(study_id=STUDY_ID, strict_ids=True, allow_duplicate_ids=False)
286
+ cmd_lock(ns)
287
+ locked = list(DATA_ROOT.glob(f"{STUDY_ID}/study_plan.v*.locked.json"))
288
+ assert len(locked) >= 1
289
+
tests/test_plots.py ADDED
@@ -0,0 +1,394 @@
1
+ """Tests for Kaplan-Meier plot generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from core.database import get_connection, init_db, DATA_ROOT
13
+ from core.planning.lock import lock_plan
14
+ from core.planning.study_plan import StudyPlan
15
+ from core.reporting.plots import generate_km_plot
16
+
17
+
18
+ STUDY_ID = "test_km_plot"
19
+
20
+
21
+ @pytest.fixture(autouse=True)
22
+ def _setup():
23
+ p = DATA_ROOT / STUDY_ID
24
+ if p.exists():
25
+ shutil.rmtree(p)
26
+ p.mkdir(parents=True)
27
+
28
+ conn = get_connection(STUDY_ID)
29
+ init_db(conn)
30
+ conn.execute(
31
+ "INSERT OR REPLACE INTO studies (id, name, created_at, data_dir, study_type, is_locked) "
32
+ "VALUES (?, ?, ?, ?, ?, ?)",
33
+ (STUDY_ID, "KM Plot Test", "2025-01-01", str(DATA_ROOT / STUDY_ID), "cohort", 0),
34
+ )
35
+ raw = f"raw_{STUDY_ID}"
36
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, "
37
+ f"pfs_days TEXT, pfs_event TEXT, treatment_arm TEXT)")
38
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('65', '100', '1', 'A')")
39
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('70', '200', '0', 'B')")
40
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('55', '150', '1', 'A')")
41
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('80', '300', '0', 'B')")
42
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('60', '50', '1', 'A')")
43
+ conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
44
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
45
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_days', 'outcome', 'time_to_event')", (STUDY_ID,))
46
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'pfs_event', 'outcome', 'categorical')", (STUDY_ID,))
47
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'treatment_arm', 'baseline', 'categorical')", (STUDY_ID,))
48
+ conn.commit()
49
+ conn.close()
50
+
51
+ # Seal outcomes so we can lock
52
+ from core.masking.gate import seal_outcomes
53
+ seal_outcomes(STUDY_ID)
54
+ yield
55
+ if p.exists():
56
+ shutil.rmtree(p)
57
+
58
+
59
+ def _setup_plan_and_result(with_completed_km: bool = True):
60
+ """Lock a plan with a KM test and insert a result."""
61
+ plan = StudyPlan(
62
+ study_id=STUDY_ID,
63
+ study_type="cohort",
64
+ primary_comparison="PFS by treatment arm",
65
+ planned_tests=[{"variable_name": "pfs_days", "test_name": "kaplan_meier_logrank",
66
+ "rationale": "Compare PFS between arms"}],
67
+ )
68
+ lock_plan(STUDY_ID, plan)
69
+
70
+ # Unmask so outcome columns are visible in the raw table
71
+ from core.masking.gate import unmask_study
72
+ unmask_study(STUDY_ID)
73
+
74
+ if with_completed_km:
75
+ conn = get_connection(STUDY_ID)
76
+ init_db(conn)
77
+ conn.execute(
78
+ """INSERT INTO analysis_results
79
+ (id, study_id, test_name, statistic, p_value, status_json,
80
+ sample_counts_json, is_pre_registered, computed_at)
81
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)""",
82
+ (1, STUDY_ID, "kaplan_meier_logrank", 5.2, 0.022,
83
+ json.dumps({"status": "completed"}),
84
+ json.dumps({"n_total": 5, "n_analyzed": 5, "n_excluded": 0}),
85
+ "2025-01-01T00:00:00"),
86
+ )
87
+ conn.commit()
88
+ conn.close()
89
+
90
+
91
+ def test_km_plot_produces_valid_svg():
92
+ """A completed KM test should produce a well-formed SVG file."""
93
+ _setup_plan_and_result(with_completed_km=True)
94
+ out = Path(tempfile.mkstemp(suffix=".svg")[1])
95
+ try:
96
+ result = generate_km_plot(STUDY_ID, test_id=1, output_path=out, fmt="svg")
97
+ assert result.exists()
98
+ assert result.suffix == ".svg"
99
+ content = result.read_text()
100
+ assert content.strip().startswith("<?xml") or content.strip().startswith("<svg")
101
+ assert "<svg" in content
102
+ finally:
103
+ out.unlink()
104
+
105
+
106
+ def test_km_plot_rejects_skipped_test():
107
+ """Plotting a skipped/errored KM test should raise a clear error."""
108
+ _setup_plan_and_result(with_completed_km=False)
109
+ conn = get_connection(STUDY_ID)
110
+ init_db(conn)
111
+ conn.execute(
112
+ """INSERT INTO analysis_results
113
+ (id, study_id, test_name, statistic, p_value, status_json,
114
+ sample_counts_json, is_pre_registered, computed_at)
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)""",
116
+ (1, STUDY_ID, "kaplan_meier_logrank", None, None,
117
+ json.dumps({"status": "skipped_assumption_violation",
118
+ "reason": "minimum expected cell count is 1.4"}),
119
+ json.dumps({"n_total": 5, "n_analyzed": 0, "n_excluded": 5}),
120
+ "2025-01-01T00:00:00"),
121
+ )
122
+ conn.commit()
123
+ conn.close()
124
+
125
+ out = Path(tempfile.mkstemp(suffix=".svg")[1])
126
+ with pytest.raises(ValueError, match="did not complete successfully"):
127
+ generate_km_plot(STUDY_ID, test_id=1, output_path=out, fmt="svg")
128
+ out.unlink()
129
+
130
+
131
+ def test_km_plot_rejects_non_km_test():
132
+ """Plotting a non-KM test should raise a clear error."""
133
+ _setup_plan_and_result(with_completed_km=False)
134
+ conn = get_connection(STUDY_ID)
135
+ init_db(conn)
136
+ conn.execute(
137
+ """INSERT INTO analysis_results
138
+ (id, study_id, test_name, statistic, p_value, status_json,
139
+ sample_counts_json, is_pre_registered, computed_at)
140
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)""",
141
+ (1, STUDY_ID, "chi_square", 3.2, 0.07,
142
+ json.dumps({"status": "completed"}),
143
+ json.dumps({"n_total": 5, "n_analyzed": 5, "n_excluded": 0}),
144
+ "2025-01-01T00:00:00"),
145
+ )
146
+ conn.commit()
147
+ conn.close()
148
+
149
+ out = Path(tempfile.mkstemp(suffix=".svg")[1])
150
+ with pytest.raises(ValueError, match="not 'kaplan_meier_logrank'"):
151
+ generate_km_plot(STUDY_ID, test_id=1, output_path=out, fmt="svg")
152
+ out.unlink()
153
+
154
+
155
+ def test_km_plot_rejects_nonexistent_test_id():
156
+ """A non-existent test_id should raise a clear error."""
157
+ _setup_plan_and_result(with_completed_km=True)
158
+ out = Path(tempfile.mkstemp(suffix=".svg")[1])
159
+ with pytest.raises(ValueError, match="not found"):
160
+ generate_km_plot(STUDY_ID, test_id=999, output_path=out, fmt="svg")
161
+ out.unlink()
162
+
163
+
164
+ def test_km_plot_contains_at_risk_median_and_censoring():
165
+ """The KM plot SVG must include the at-risk table, median lines, and censoring note."""
166
+ _setup_plan_and_result(with_completed_km=True)
167
+ # Add more data so the at-risk table is non-trivial
168
+ conn = get_connection(STUDY_ID)
169
+ raw = f"raw_{STUDY_ID}"
170
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('45', '400', '1', 'A')")
171
+ conn.execute(f"INSERT INTO {raw} (age, pfs_days, pfs_event, treatment_arm) VALUES ('50', '500', '0', 'B')")
172
+ conn.commit()
173
+ conn.close()
174
+
175
+ out = Path(tempfile.mkstemp(suffix=".svg")[1])
176
+ try:
177
+ result = generate_km_plot(STUDY_ID, test_id=1, output_path=out, fmt="svg")
178
+ content = result.read_text()
179
+
180
+ assert content.strip().startswith("<?xml") or content.strip().startswith("<svg")
181
+ assert "<svg" in content
182
+
183
+ # Censoring legend entry — legend text appears as SVG <text> elements
184
+ assert "Censored" in content, "SVG should contain censoring legend entry"
185
+
186
+ # At-risk table: rendered by ax.text() as path characters, but
187
+ # group names (e.g. "A (n=") appear in the legend as SVG text
188
+ assert "A (n=" in content or "n=3" in content or "n=" in content
189
+
190
+ # Confirm no hazard ratio appears anywhere
191
+ assert "hazard" not in content.lower(), "SVG should NOT contain hazard ratio text"
192
+ finally:
193
+ out.unlink()
194
+
195
+
196
+ def test_all_styles_produce_valid_svg():
197
+ """All three style presets must produce valid SVG output."""
198
+ _setup_plan_and_result(with_completed_km=True)
199
+ for style in ("clean", "scientific", "presentation"):
200
+ out = Path(tempfile.mkstemp(suffix=".svg")[1])
201
+ try:
202
+ result = generate_km_plot(STUDY_ID, test_id=1, output_path=out, fmt="svg", style=style)
203
+ content = result.read_text()
204
+ assert content.strip().startswith("<?xml") or content.strip().startswith("<svg"), \
205
+ f"Style '{style}' produced non-SVG output"
206
+ assert "<svg" in content, f"Style '{style}' produced non-SVG output"
207
+ finally:
208
+ out.unlink()
209
+
210
+
211
+ def test_clean_style_no_hr_without_cox():
212
+ """Clean-style plot must NOT show HR when no Cox PH result exists."""
213
+ _setup_plan_and_result(with_completed_km=True)
214
+ out = Path(tempfile.mkstemp(suffix=".svg")[1])
215
+ try:
216
+ result = generate_km_plot(STUDY_ID, test_id=1, output_path=out, fmt="svg", style="clean")
217
+ content = result.read_text()
218
+ assert "hazard" not in content.lower(), "Clean style should not show HR without Cox result"
219
+ # Must show the log-rank p-value instead
220
+ assert "Log-rank" in content or "P =" in content, \
221
+ "Clean style should show Log-rank or P = in the annotation"
222
+ finally:
223
+ out.unlink()
224
+
225
+
226
+ def test_clean_style_shows_hr_with_cox():
227
+ """Clean-style plot SHOULD show HR when a completed Cox PH result exists."""
228
+ _setup_plan_and_result(with_completed_km=True)
229
+ # Insert a matching Cox PH result
230
+ conn = get_connection(STUDY_ID)
231
+ init_db(conn)
232
+ conn.execute(
233
+ """INSERT INTO analysis_results
234
+ (id, study_id, test_name, statistic, p_value, ci_lower, ci_upper, status_json,
235
+ sample_counts_json, is_pre_registered, computed_at)
236
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)""",
237
+ (99, STUDY_ID, "cox_proportional_hazards", 1.32, 0.032, 1.05, 1.67,
238
+ json.dumps({"status": "completed"}),
239
+ json.dumps({"n_total": 5, "n_analyzed": 5, "n_excluded": 0}),
240
+ "2025-01-01T00:00:00"),
241
+ )
242
+ conn.commit()
243
+ conn.close()
244
+
245
+ # Verify the lookup function finds the Cox result
246
+ from core.reporting.plots import _lookup_cox_hr
247
+ cox = _lookup_cox_hr(STUDY_ID, "treatment_arm")
248
+ assert cox is not None, "Cox lookup should find the inserted result"
249
+ assert abs(cox["hr"] - 1.32) < 0.001
250
+ assert abs(cox["p_value"] - 0.032) < 0.001
251
+
252
+
253
+ def test_default_style_is_clean():
254
+ """Calling generate_km_plot without style= should produce the clean preset."""
255
+ _setup_plan_and_result(with_completed_km=True)
256
+ out_default = Path(tempfile.mkstemp(suffix=".svg")[1])
257
+ out_clean = Path(tempfile.mkstemp(suffix=".svg")[1])
258
+ try:
259
+ result_default = generate_km_plot(STUDY_ID, test_id=1, output_path=out_default, fmt="svg", style="clean")
260
+ result_clean = generate_km_plot(STUDY_ID, test_id=1, output_path=out_clean, fmt="svg", style="clean")
261
+ content_default = result_default.read_text()
262
+ content_clean = result_clean.read_text()
263
+ # Same style param should produce identical SVGs
264
+ # (pandas/matplotlib floating-point determinism is sufficient for this check)
265
+ assert len(content_default) == len(content_clean), \
266
+ "Default and clean-style SVGs should be identical"
267
+ finally:
268
+ out_default.unlink()
269
+ out_clean.unlink()
270
+
271
+
272
+ def test_style_all_produces_three_files(monkeypatch):
273
+ """--style all must generate three distinct, valid SVG files."""
274
+ _setup_plan_and_result(with_completed_km=True)
275
+ import argparse
276
+ from core.cli.main import cmd_plot_km
277
+ from core.database import DATA_ROOT
278
+
279
+ # Clean up any previous files
280
+ for f in DATA_ROOT.glob(f"{STUDY_ID}/km_plot_1_*.svg"):
281
+ f.unlink()
282
+
283
+ ns = argparse.Namespace(
284
+ study_id=STUDY_ID, test_id=1,
285
+ format="svg", no_risk_table=False, no_medians=False,
286
+ output=None, time_unit="months", style="all",
287
+ )
288
+ cmd_plot_km(ns)
289
+
290
+ for style_name in ("clean", "scientific", "presentation"):
291
+ path = DATA_ROOT / STUDY_ID / f"km_plot_1_{style_name}.svg"
292
+ assert path.exists(), f"Missing {path}"
293
+ content = path.read_text()
294
+ assert "<?xml" in content[:200] or "<svg" in content[:200], \
295
+ f"{style_name} SVG is not valid"
296
+ path.unlink()
297
+
298
+
299
+ def test_single_styles_dont_overwrite():
300
+ """Sequential plot-km calls with different --style values must each
301
+ produce a distinct file, not silently overwrite the previous one."""
302
+ _setup_plan_and_result(with_completed_km=True)
303
+ import argparse
304
+ from core.cli.main import cmd_plot_km
305
+ from core.database import DATA_ROOT
306
+
307
+ # Clean up
308
+ for f in DATA_ROOT.glob(f"{STUDY_ID}/km_plot_1_*.svg"):
309
+ f.unlink()
310
+
311
+ for style_name in ("clean", "scientific", "presentation"):
312
+ ns = argparse.Namespace(
313
+ study_id=STUDY_ID, test_id=1,
314
+ format="svg", no_risk_table=False, no_medians=False,
315
+ output=None, time_unit="months", style=style_name,
316
+ )
317
+ cmd_plot_km(ns)
318
+
319
+ # All three files should exist simultaneously
320
+ existing = []
321
+ for style_name in ("clean", "scientific", "presentation"):
322
+ path = DATA_ROOT / STUDY_ID / f"km_plot_1_{style_name}.svg"
323
+ assert path.exists(), f"Missing {path} after sequential invocation"
324
+ existing.append(path)
325
+
326
+ # All three should have different sizes (different styles)
327
+ sizes = {p.stat().st_size for p in existing}
328
+ assert len(sizes) >= 2, \
329
+ f"Files should have different sizes (different styles), got {sizes}"
330
+
331
+ for p in existing:
332
+ p.unlink()
333
+
334
+
335
+ def test_scientific_vs_presentation_configs_differ():
336
+ """Scientific and presentation presets must have meaningfully different CI alpha values."""
337
+ from core.reporting.plots import _STYLES
338
+ sci = _STYLES["scientific"]
339
+ pres = _STYLES["presentation"]
340
+ # CI alpha — must differ by at least 0.1 to be visually apparent
341
+ assert pres.ci_alpha - sci.ci_alpha >= 0.10, \
342
+ f"CI alpha should differ by >=0.10 (sci={sci.ci_alpha}, pres={pres.ci_alpha})"
343
+ # Linewidth — must differ
344
+ assert pres.linewidth > sci.linewidth, \
345
+ f"Presentation linewidth ({pres.linewidth}) should be > scientific ({sci.linewidth})"
346
+ # Stats box — both use box but scientific should be more compact
347
+ assert pres.stats_fontsize > sci.stats_fontsize, \
348
+ f"Presentation stats font ({pres.stats_fontsize}) should be > scientific ({sci.stats_fontsize})"
349
+
350
+
351
+ def test_scientific_vs_presentation_svg_fill_opacity_differs():
352
+ """Actual rendered SVG fill-opacity must differ between scientific and presentation."""
353
+ _setup_plan_and_result(with_completed_km=True)
354
+ import re
355
+ out_sci = Path(tempfile.mkstemp(suffix=".svg")[1])
356
+ out_pres = Path(tempfile.mkstemp(suffix=".svg")[1])
357
+ try:
358
+ r_sci = generate_km_plot(STUDY_ID, test_id=1, output_path=out_sci, fmt="svg", style="scientific")
359
+ r_pres = generate_km_plot(STUDY_ID, test_id=1, output_path=out_pres, fmt="svg", style="presentation")
360
+ sci_svg = r_sci.read_text()
361
+ pres_svg = r_pres.read_text()
362
+
363
+ # Extract CI band fill-opacity values (the blue/orange bands)
364
+ sci_ci_ops = set(re.findall(r'fill: #1f77b4; fill-opacity: ([0-9.]+)', sci_svg))
365
+ pres_ci_ops = set(re.findall(r'fill: #1f77b4; fill-opacity: ([0-9.]+)', pres_svg))
366
+
367
+ assert sci_ci_ops, "No CI band found in scientific SVG"
368
+ assert pres_ci_ops, "No CI band found in presentation SVG"
369
+
370
+ sci_val = float(list(sci_ci_ops)[0])
371
+ pres_val = float(list(pres_ci_ops)[0])
372
+
373
+ # The difference must be at least 0.15
374
+ assert pres_val - sci_val >= 0.15, \
375
+ f"Rendered CI fill-opacity too close: sci={sci_val}, pres={pres_val}"
376
+
377
+ # Also verify median line stroke-width differs
378
+ sci_med_sw = set(re.findall(r'stroke-width: ([0-9.]+)', sci_svg))
379
+ pres_med_sw = set(re.findall(r'stroke-width: ([0-9.]+)', pres_svg))
380
+ finally:
381
+ out_sci.unlink()
382
+ out_pres.unlink()
383
+
384
+
385
+ def test_km_plot_ci_clamped_at_t0():
386
+ """Confidence interval at t=0 must be clamped to [1.0, 1.0] (zero width at t=0)."""
387
+ _setup_plan_and_result(with_completed_km=True)
388
+ out_path = Path(tempfile.mkstemp(suffix=".svg")[1])
389
+ try:
390
+ generate_km_plot(STUDY_ID, test_id=1, output_path=out_path, fmt="svg", style="clean")
391
+ assert out_path.exists()
392
+ finally:
393
+ out_path.unlink()
394
+