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,380 @@
1
+ """Tests for inferential statistics."""
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import pytest
6
+ from lifelines.exceptions import ConvergenceWarning
7
+
8
+ from core.stats.inferential import run_test
9
+
10
+
11
+ def _make_df():
12
+ """Simple two-group data."""
13
+ return pd.DataFrame({
14
+ "group": ["A"] * 20 + ["B"] * 20,
15
+ "outcome": [1] * 12 + [0] * 8 + [1] * 6 + [0] * 14,
16
+ "continuous": [65 + i for i in range(20)] + [70 + i for i in range(20)],
17
+ "time": [100 + i * 10 for i in range(40)],
18
+ "event": [1 if i < 10 else 0 for i in range(20)] + [1 if i < 15 else 0 for i in range(20)],
19
+ })
20
+
21
+
22
+ def test_chi_square():
23
+ df = _make_df()
24
+ result = run_test("chi_square", df, "outcome", "group")
25
+ assert result["test_name"] == "chi_square"
26
+ assert result["statistic"] is not None
27
+ assert result["p_value"] is not None
28
+
29
+
30
+ def test_fishers_exact():
31
+ df = _make_df()
32
+ result = run_test("fishers_exact", df, "outcome", "group")
33
+ assert result["test_name"] == "fishers_exact"
34
+ assert result["statistic"] is not None
35
+
36
+
37
+ def test_t_test():
38
+ df = _make_df()
39
+ result = run_test("t_test", df, "outcome", "group")
40
+ assert result["test_name"] == "t_test"
41
+ assert result["statistic"] is not None
42
+ assert result["ci_lower"] is not None
43
+ assert result["ci_upper"] is not None
44
+ assert result["ci_lower"] < result["ci_upper"]
45
+
46
+
47
+ def test_mann_whitney():
48
+ df = _make_df()
49
+ result = run_test("mann_whitney_u", df, "continuous", "group")
50
+ assert result["test_name"] == "mann_whitney_u"
51
+ assert result["statistic"] is not None
52
+
53
+
54
+ def test_anova():
55
+ df = pd.DataFrame({
56
+ "group": ["A"] * 10 + ["B"] * 10 + ["C"] * 10,
57
+ "val": [60 + i for i in range(10)] + [70 + i for i in range(10)] + [80 + i for i in range(10)],
58
+ })
59
+ result = run_test("anova", df, "val", "group")
60
+ assert result["test_name"] == "anova"
61
+ assert result["statistic"] is not None
62
+
63
+
64
+ def test_kruskal_wallis():
65
+ df = pd.DataFrame({
66
+ "group": ["A"] * 10 + ["B"] * 10 + ["C"] * 10,
67
+ "val": [60 + i for i in range(10)] + [70 + i for i in range(10)] + [80 + i for i in range(10)],
68
+ })
69
+ result = run_test("kruskal_wallis", df, "val", "group")
70
+ assert result["test_name"] == "kruskal_wallis"
71
+ assert result["statistic"] is not None
72
+
73
+
74
+ def test_kaplan_meier_logrank():
75
+ df = _make_df()
76
+ result = run_test("kaplan_meier_logrank", df, "outcome",
77
+ "group", time_col="time", event_col="event")
78
+ assert result["test_name"] == "kaplan_meier_logrank"
79
+ assert result["statistic"] is not None
80
+ assert result["p_value"] is not None
81
+
82
+
83
+ def test_cox_ph():
84
+ df = _make_df()
85
+ result = run_test("cox_proportional_hazards", df, "outcome",
86
+ "group", time_col="time", event_col="event",
87
+ covariates=["continuous"])
88
+ assert result["test_name"] == "cox_proportional_hazards"
89
+ # May or may not converge on tiny data, but should not crash
90
+ assert result is not None
91
+
92
+
93
+ def test_unknown_test():
94
+ df = _make_df()
95
+ result = run_test("imaginary_test", df, "outcome", "group")
96
+ assert "error" in str(result.get("params", {}))
97
+
98
+
99
+ def test_kaplan_meier_rejects_missing_event_col():
100
+ """Survival test on a duration-only column must raise a clear ValueError."""
101
+ df = pd.DataFrame({
102
+ "pfs_days": [100, 200, 150],
103
+ "treatment_arm": ["A", "B", "A"],
104
+ })
105
+ with pytest.raises(ValueError, match="no linked event/censoring column"):
106
+ run_test("kaplan_meier_logrank", df, outcome_col="pfs_days",
107
+ group_col="treatment_arm", time_col="pfs_days",
108
+ event_col="pfs_event")
109
+
110
+
111
+ def test_kaplan_meier_rejects_none_event_col():
112
+ """Survival test with event_col=None must raise a clear ValueError."""
113
+ df = pd.DataFrame({
114
+ "pfs_days": [100, 200, 150],
115
+ "treatment_arm": ["A", "B", "A"],
116
+ })
117
+ with pytest.raises(ValueError, match="no linked event/censoring column"):
118
+ run_test("kaplan_meier_logrank", df, outcome_col="pfs_days",
119
+ group_col="treatment_arm", time_col="pfs_days",
120
+ event_col=None)
121
+
122
+
123
+ def test_cox_ph_rejects_missing_event_col():
124
+ """Cox PH on a duration-only column must raise a clear ValueError."""
125
+ df = pd.DataFrame({
126
+ "pfs_days": [100, 200, 150],
127
+ "treatment_arm": ["A", "B", "A"],
128
+ })
129
+ with pytest.raises(ValueError, match="no linked event/censoring column"):
130
+ run_test("cox_proportional_hazards", df, outcome_col="pfs_days",
131
+ group_col="treatment_arm", time_col="pfs_days",
132
+ event_col="pfs_event", covariates=[])
133
+
134
+
135
+ @pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
136
+ @pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
137
+ def test_cox_ph_model_treats_string_stored_numeric_as_continuous():
138
+ """String-dtype column (simulating SQLite TEXT ingest) with 'continuous'
139
+ in var_types must be converted to numeric, not treated as categorical.
140
+
141
+ Without var_types, a string 'age' column produces one lifelines dummy
142
+ per unique value (age[T.45], age[T.70], ...), causing singular Hessian.
143
+ With var_types={'age': 'continuous'}, it becomes a single coefficient.
144
+ """
145
+ df = pd.DataFrame({
146
+ "time": ["100", "200", "150", "300"],
147
+ "event": ["1", "0", "1", "0"],
148
+ "arm": ["A", "A", "B", "B"],
149
+ "age": ["45", "70", "55", "60"],
150
+ })
151
+ var_types = {"age": "continuous", "arm": "categorical",
152
+ "time": "time_to_event", "event": "categorical"}
153
+
154
+ result = run_test(
155
+ "cox_ph_model", df,
156
+ outcome_col="time", group_col="arm",
157
+ time_col="time", event_col="event",
158
+ covariates=["age"],
159
+ var_types=var_types,
160
+ )
161
+ # Should converge with a single age coefficient (HR > 0)
162
+ assert result["statistic"] is not None, "HR must not be None"
163
+ assert result["statistic"] > 0, "HR must be positive"
164
+ assert result["p_value"] is not None
165
+
166
+ # Per-covariate results: age should appear as one row (no per-value dummies)
167
+ cov_results = result.get("params", {}).get("per_covariate_results", [])
168
+ age_rows = [r for r in cov_results if r["covariate"] == "age"]
169
+ assert len(age_rows) == 1, (
170
+ f"Expected 1 coefficient for 'age', got {len(age_rows)} — "
171
+ f"variable was likely treated as categorical: {cov_results}"
172
+ )
173
+
174
+
175
+ @pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
176
+ @pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
177
+ def test_cox_ph_model_without_var_types_falls_back_to_dtype():
178
+ """When var_types is None, fall back to pandas dtype check."""
179
+ df = pd.DataFrame({
180
+ "time": [100.0, 200.0, 150.0, 300.0],
181
+ "event": [1, 0, 1, 0],
182
+ "arm": ["A", "A", "B", "B"],
183
+ "age": [45.0, 70.0, 55.0, 60.0],
184
+ })
185
+ # Numeric dtypes should be detected as numeric even without var_types
186
+ result = run_test(
187
+ "cox_ph_model", df,
188
+ outcome_col="time", group_col="arm",
189
+ time_col="time", event_col="event",
190
+ covariates=["age"],
191
+ )
192
+ assert result["statistic"] is not None
193
+ assert result["statistic"] > 0
194
+
195
+
196
+ def test_cox_ph_model_returns_hr_scale_ci():
197
+ """CI bounds must be on HR scale (positive), not log-HR (coefficient scale)."""
198
+ df = pd.DataFrame({
199
+ "time": [100.0, 200.0, 150.0, 300.0, 180.0, 220.0, 90.0, 350.0],
200
+ "event": [1, 0, 1, 0, 1, 0, 1, 0],
201
+ "arm": ["A", "A", "B", "B", "A", "A", "B", "B"],
202
+ "age": [45.0, 70.0, 55.0, 60.0, 50.0, 65.0, 58.0, 62.0],
203
+ "marker": [1.2, 3.4, 2.1, 4.5, 1.8, 2.9, 3.2, 4.1],
204
+ })
205
+ result = run_test(
206
+ "cox_ph_model", df,
207
+ outcome_col="time", group_col="arm",
208
+ time_col="time", event_col="event",
209
+ covariates=["age", "marker"],
210
+ )
211
+ assert result["statistic"] is not None
212
+
213
+ # Primary treatment: CI must be on HR scale (both > 0)
214
+ assert result["ci_lower"] > 0, f"ci_lower={result['ci_lower']} must be >0 (HR scale)"
215
+ assert result["ci_lower"] < result["statistic"] < result["ci_upper"], \
216
+ f"CI ({result['ci_lower']}, {result['ci_upper']}) must contain HR ({result['statistic']})"
217
+
218
+ # Per-covariate: every CI must be on HR scale
219
+ for cov in result.get("params", {}).get("per_covariate_results", []):
220
+ assert cov["ci_lower"] > 0, \
221
+ f"{cov['covariate']}: ci_lower={cov['ci_lower']} must be >0 (HR scale)"
222
+ assert cov["ci_lower"] < cov["hr"] < cov["ci_upper"], \
223
+ f"{cov['covariate']}: CI ({cov['ci_lower']}, {cov['ci_upper']}) must contain HR ({cov['hr']})"
224
+
225
+
226
+ @pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
227
+ @pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
228
+ def test_cox_ph_model_overflow_ci_upper_becomes_inf():
229
+ """Near-complete separation produces inf upper CI — must not crash."""
230
+ df = pd.DataFrame({
231
+ "time": [100.0, 200.0, 150.0, 300.0],
232
+ "event": [1, 0, 1, 0],
233
+ "arm": ["A", "A", "B", "B"],
234
+ "age": [45.0, 70.0, 55.0, 60.0],
235
+ })
236
+ result = run_test(
237
+ "cox_ph_model", df,
238
+ outcome_col="time", group_col="arm",
239
+ time_col="time", event_col="event",
240
+ covariates=["age"],
241
+ )
242
+ ci_upper = result["ci_upper"]
243
+ assert np.isinf(ci_upper), f"Expected inf upper CI for near-complete separation, got {ci_upper}"
244
+ assert result["ci_lower"] is not None
245
+
246
+
247
+ @pytest.mark.filterwarnings("ignore::lifelines.exceptions.ConvergenceWarning")
248
+ @pytest.mark.filterwarnings("ignore:overflow encountered in exp:RuntimeWarning")
249
+ def test_cox_ph_model_persists_params_to_db():
250
+ """Cox PH model-level fields and covariate rows must be persisted to DB."""
251
+ import json, shutil
252
+ from core.database import get_connection, init_db, migrate_db, DATA_ROOT
253
+
254
+ study_id = "test_cox_ph_persist"
255
+ study_dir = DATA_ROOT / study_id
256
+ if study_dir.exists():
257
+ shutil.rmtree(study_dir)
258
+
259
+ conn = get_connection(study_id)
260
+ init_db(conn)
261
+ conn.execute(
262
+ "INSERT INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
263
+ (study_id, "test", "2025-01-01T00:00:00", str(study_dir)),
264
+ )
265
+
266
+ df = pd.DataFrame({
267
+ "time": [100.0, 200.0, 150.0, 300.0, 180.0, 220.0],
268
+ "event": [1, 0, 1, 0, 1, 0],
269
+ "arm": ["A", "A", "B", "B", "A", "A"],
270
+ "age": [45.0, 70.0, 55.0, 60.0, 50.0, 65.0],
271
+ })
272
+
273
+ result = run_test(
274
+ "cox_ph_model", df,
275
+ outcome_col="time", group_col="arm",
276
+ time_col="time", event_col="event",
277
+ covariates=["age"],
278
+ var_types={"age": "continuous", "arm": "categorical"},
279
+ )
280
+
281
+ now = "2025-01-01T00:00:00"
282
+ params = result.get("params", {})
283
+ cursor = conn.execute(
284
+ """INSERT INTO analysis_results
285
+ (study_id, test_name, statistic, p_value, ci_lower, ci_upper,
286
+ effect_size_json, sample_counts_json, status_json,
287
+ is_pre_registered, provenance_json, computed_at,
288
+ lr_test_p, concordance_index, ph_diagnostics_json)
289
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?)""",
290
+ (study_id, "cox_ph_model", result["statistic"], result["p_value"],
291
+ result.get("ci_lower"), result.get("ci_upper"),
292
+ json.dumps(result["effect_size"]) if result.get("effect_size") else None,
293
+ json.dumps(result["sample_counts"]) if result.get("sample_counts") else None,
294
+ json.dumps({"status": "completed"}),
295
+ json.dumps({"plan_version": 1}), now,
296
+ params.get("lr_test_p_value"), params.get("concordance_index"),
297
+ json.dumps(params.get("assumption_diagnostics")) if params.get("assumption_diagnostics") else None),
298
+ )
299
+ result_id = cursor.lastrowid
300
+
301
+ cov_results = params.get("per_covariate_results", [])
302
+ for cr in cov_results:
303
+ conn.execute(
304
+ """INSERT INTO analysis_covariate_results
305
+ (result_id, covariate, hr, ci_lower, ci_upper,
306
+ wald_p, coef, se, z)
307
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
308
+ (result_id,
309
+ cr.get("covariate"), cr.get("hr"),
310
+ cr.get("ci_lower"), cr.get("ci_upper"),
311
+ cr.get("wald_p"), cr.get("coef"),
312
+ cr.get("se"), cr.get("z")),
313
+ )
314
+ conn.commit()
315
+
316
+ # Verify model-level fields persisted
317
+ cur = conn.execute("SELECT * FROM analysis_results WHERE id=?", (result_id,))
318
+ row = cur.fetchone()
319
+ assert row is not None
320
+ assert row["test_name"] == "cox_ph_model"
321
+ if params.get("lr_test_p_value") is not None:
322
+ assert abs(row["lr_test_p"] - params["lr_test_p_value"]) < 1e-6
323
+ if params.get("concordance_index") is not None:
324
+ assert abs(row["concordance_index"] - params["concordance_index"]) < 1e-6
325
+
326
+ # Verify covariate rows queryable via SQL
327
+ cur = conn.execute(
328
+ "SELECT * FROM analysis_covariate_results WHERE result_id=? ORDER BY id", (result_id,)
329
+ )
330
+ db_rows = cur.fetchall()
331
+ assert len(db_rows) == len(cov_results)
332
+ for db_row, expected in zip(db_rows, cov_results):
333
+ assert db_row["covariate"] == expected["covariate"]
334
+ if expected.get("hr") is not None:
335
+ assert abs(db_row["hr"] - expected["hr"]) < 1e-6
336
+
337
+ conn.close()
338
+
339
+
340
+ def test_non_cox_result_inserts_with_null_new_columns():
341
+ """Non-Cox-PH result types must insert cleanly with new columns as NULL."""
342
+ import json, shutil
343
+ from core.database import get_connection, init_db, DATA_ROOT
344
+
345
+ study_id = "test_non_cox_null"
346
+ study_dir = DATA_ROOT / study_id
347
+ if study_dir.exists():
348
+ shutil.rmtree(study_dir)
349
+
350
+ conn = get_connection(study_id)
351
+ init_db(conn)
352
+ conn.execute(
353
+ "INSERT INTO studies (id, name, created_at, data_dir) VALUES (?, ?, ?, ?)",
354
+ (study_id, "test", "2025-01-01T00:00:00", str(study_dir)),
355
+ )
356
+
357
+ now = "2025-01-01T00:00:00"
358
+ # Insert a chi-square result
359
+ conn.execute(
360
+ """INSERT INTO analysis_results
361
+ (study_id, test_name, statistic, p_value, status_json,
362
+ is_pre_registered, provenance_json, computed_at)
363
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?)""",
364
+ (study_id, "chi_square", 3.84, 0.05,
365
+ json.dumps({"status": "completed"}),
366
+ json.dumps({"plan_version": 1}), now),
367
+ )
368
+ conn.commit()
369
+
370
+ cur = conn.execute(
371
+ "SELECT * FROM analysis_results WHERE study_id=? AND test_name=?", (study_id, "chi_square")
372
+ )
373
+ row = cur.fetchone()
374
+ assert row is not None
375
+ # New columns must be NULL for non-Cox results
376
+ assert row["lr_test_p"] is None
377
+ assert row["concordance_index"] is None
378
+ assert row["ph_diagnostics_json"] is None
379
+
380
+ conn.close()
@@ -0,0 +1,172 @@
1
+ """Tests for STROBE checklist compliance checker."""
2
+
3
+ from __future__ import annotations
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from core.database import get_connection, init_db, DATA_ROOT
10
+ from core.masking.gate import seal_outcomes
11
+ from core.planning.study_plan import StudyPlan
12
+ from core.planning.lock import lock_plan
13
+ from core.reporting.strobe_checklist import check_study, generate_report, STROBE_ITEMS
14
+
15
+ STUDY_ID = "test_strobe"
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def _setup():
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, "STROBE Test", "2025-01-01T00:00:00",
25
+ str(Path("data/studies") / STUDY_ID), "cohort"),
26
+ )
27
+ raw = f"raw_{STUDY_ID}"
28
+ conn.execute(f"CREATE TABLE IF NOT EXISTS {raw} (row_id INTEGER PRIMARY KEY, age TEXT, response TEXT, treatment_arm TEXT)")
29
+ conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('65', 'CR', 'A')")
30
+ conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
31
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
32
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'response', 'outcome', 'categorical')", (STUDY_ID,))
33
+ conn.commit()
34
+ conn.close()
35
+ yield
36
+ p = DATA_ROOT / STUDY_ID
37
+ if p.exists():
38
+ shutil.rmtree(p)
39
+
40
+
41
+ def test_check_study_returns_list():
42
+ items = check_study(STUDY_ID)
43
+ assert len(items) > 0
44
+
45
+
46
+ def test_check_study_filters_by_type():
47
+ """Only items applicable to 'cohort' should be in the response."""
48
+ items = check_study(STUDY_ID)
49
+ for item in items:
50
+ assert "cohort" in item.applies_to
51
+
52
+
53
+ def test_item_3_satisfied_with_locked_plan():
54
+ """Item 3 (objectives/hypotheses) should be satisfied after locking a plan."""
55
+ plan = StudyPlan(study_id=STUDY_ID, study_type="cohort",
56
+ primary_comparison="survival by treatment arm")
57
+ lock_plan(STUDY_ID, plan)
58
+
59
+ items = check_study(STUDY_ID)
60
+ item3 = next(i for i in items if i.item_id == "3")
61
+ assert item3.satisfied is True
62
+
63
+
64
+ def test_generate_report_includes_status_counts():
65
+ report = generate_report(STUDY_ID)
66
+ assert "STROBE Compliance Report" in report
67
+ # Status symbols: ✓ satisfied, [ ] pending, ✗ unsatisfied
68
+ assert "✓" in report or " [" in report or "✗" in report
69
+
70
+
71
+ def test_strobe_items_have_required_fields():
72
+ for item in STROBE_ITEMS:
73
+ assert item.item_id
74
+ assert item.section
75
+ assert item.description
76
+ assert len(item.applies_to) > 0
77
+
78
+
79
+ def test_strobe_items_cover_22_base():
80
+ """Should have at least 22 unique items (some are design-specific)."""
81
+ ids = set(i.item_id for i in STROBE_ITEMS)
82
+ assert len(ids) >= 22
83
+
84
+
85
+ def _add_completed_result(study_id, test_name="chi_square", extra=True):
86
+ """Insert a completed analysis result for the test study."""
87
+ conn = get_connection(study_id)
88
+ init_db(conn)
89
+ from datetime import datetime, timezone
90
+ import json
91
+ conn.execute(
92
+ """INSERT INTO analysis_results
93
+ (study_id, test_name, statistic, p_value, status_json,
94
+ sample_counts_json, is_pre_registered, computed_at)
95
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?)""",
96
+ (study_id, test_name, 5.5, 0.24,
97
+ json.dumps({"status": "completed", "reason": None}),
98
+ json.dumps({"n_total": 100, "n_analyzed": 95, "n_excluded": 5}),
99
+ datetime.now(timezone.utc).isoformat()),
100
+ )
101
+ conn.commit()
102
+ conn.close()
103
+
104
+
105
+ def test_items_18_22_satisfied_with_analyses_no_draft():
106
+ """Items 18-22 should be satisfied when analyses exist, even without writing draft."""
107
+ plan = StudyPlan(
108
+ study_id=STUDY_ID, study_type="cohort",
109
+ primary_comparison="survival by treatment arm",
110
+ planned_tests=[{"variable_name": "response", "test_name": "chi_square"}],
111
+ )
112
+ lock_plan(STUDY_ID, plan)
113
+ _add_completed_result(STUDY_ID)
114
+
115
+ items = check_study(STUDY_ID)
116
+
117
+ for item in items:
118
+ if item.item_id in ("18", "19", "20", "21", "22"):
119
+ assert item.satisfied, (
120
+ f"Item {item.item_id} should be satisfied: {item.evidence}"
121
+ )
122
+
123
+
124
+ def test_strobe_order_independence():
125
+ """Strobe-check must produce consistent items 18-22 regardless of draft-file existence.
126
+
127
+ The live draft is generated in-memory from study data, so calling
128
+ check_study() before and after a write_draft() must report the same
129
+ satisfied/status for the draft-dependent items.
130
+ """
131
+ plan = StudyPlan(
132
+ study_id=STUDY_ID, study_type="cohort",
133
+ primary_comparison="survival by treatment arm",
134
+ planned_tests=[{"variable_name": "response", "test_name": "chi_square"}],
135
+ )
136
+ lock_plan(STUDY_ID, plan)
137
+ _add_completed_result(STUDY_ID)
138
+
139
+ # Strobe check when no draft file exists on disk → uses live in-memory draft
140
+ items_no_draft = check_study(STUDY_ID)
141
+
142
+ # Write draft to disk
143
+ from core.reporting.manuscript_draft import write_draft
144
+ write_draft(STUDY_ID)
145
+ assert (DATA_ROOT / STUDY_ID / "manuscript_draft.md").exists()
146
+
147
+ # Second strobe check (draft file now exists)
148
+ items_with_draft = check_study(STUDY_ID)
149
+
150
+ # Items 18-22 must be identical — the live draft always wins
151
+ for item_no, item_with in zip(items_no_draft, items_with_draft):
152
+ if item_no.item_id in ("18", "19", "20", "21", "22"):
153
+ assert item_no.satisfied == item_with.satisfied, (
154
+ f"Item {item_no.item_id}: satisfied differs "
155
+ f"(no_draft={item_no.satisfied}, with_draft={item_with.satisfied})"
156
+ )
157
+ assert item_no.status == item_with.status, (
158
+ f"Item {item_no.item_id}: status differs "
159
+ f"(no_draft={item_no.status}, with_draft={item_with.status})"
160
+ )
161
+
162
+
163
+ def test_item_22_non_bracketed_funding():
164
+ """Item 22 should be satisfied because funding text is not in brackets."""
165
+ plan = StudyPlan(study_id=STUDY_ID, study_type="cohort",
166
+ primary_comparison="test")
167
+ lock_plan(STUDY_ID, plan)
168
+ _add_completed_result(STUDY_ID)
169
+
170
+ items = check_study(STUDY_ID)
171
+ item22 = next(i for i in items if i.item_id == "22")
172
+ assert item22.satisfied, f"Item 22 should be satisfied: {item22.evidence}"