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,164 @@
1
+ """Tests for Excel report export."""
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.excel_export import generate_excel_report
16
+
17
+
18
+ STUDY_ID = "test_excel"
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, "Excel 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, response TEXT, treatment_arm TEXT)")
37
+ conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('65', 'CR', 'A')")
38
+ conn.execute(f"INSERT INTO {raw} (age, response, treatment_arm) VALUES ('70', 'PR', 'B')")
39
+ conn.execute("DELETE FROM variables WHERE study_id=?", (STUDY_ID,))
40
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'age', 'baseline', 'continuous')", (STUDY_ID,))
41
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'response', 'outcome', 'categorical')", (STUDY_ID,))
42
+ conn.execute("INSERT INTO variables (study_id, column_name, role, data_type) VALUES (?, 'treatment_arm', 'baseline', 'categorical')", (STUDY_ID,))
43
+ conn.commit()
44
+ conn.close()
45
+
46
+ from core.masking.gate import seal_outcomes
47
+ seal_outcomes(STUDY_ID)
48
+ yield
49
+ if p.exists():
50
+ shutil.rmtree(p)
51
+
52
+
53
+ def _setup_plan_and_result():
54
+ plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test",
55
+ planned_tests=[{"variable_name": "response", "test_name": "chi_square"}])
56
+ lock_plan(STUDY_ID, plan)
57
+ conn = get_connection(STUDY_ID)
58
+ init_db(conn)
59
+ conn.execute(
60
+ "INSERT INTO analysis_results (id, study_id, test_name, statistic, p_value, status_json, "
61
+ "sample_counts_json, is_pre_registered, computed_at) "
62
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
63
+ (1, STUDY_ID, "chi_square", 5.2, 0.022,
64
+ json.dumps({"status": "completed"}),
65
+ json.dumps({"n_total": 2, "n_analyzed": 2, "n_excluded": 0}),
66
+ "2025-01-01T00:00:00"),
67
+ )
68
+ conn.commit()
69
+ conn.close()
70
+
71
+
72
+ def test_excel_report_generates_valid_xlsx():
73
+ """A completed study should produce a valid .xlsx file with 4 sheets."""
74
+ _setup_plan_and_result()
75
+ out = Path(tempfile.mkstemp(suffix=".xlsx")[1])
76
+ try:
77
+ result = generate_excel_report(STUDY_ID, output_path=out)
78
+ assert result.exists()
79
+ assert result.suffix == ".xlsx"
80
+ import openpyxl
81
+ wb = openpyxl.load_workbook(str(result))
82
+ sheet_names = wb.sheetnames
83
+ assert len(sheet_names) == 4, f"Expected 4 sheets, got {len(sheet_names)}: {sheet_names}"
84
+ # Check expected tab names
85
+ assert "Executive Summary" in sheet_names
86
+ assert "Table 1 - Baseline" in sheet_names
87
+ assert "Statistical Analyses" in sheet_names
88
+ assert "Audit & Hash Manifest" in sheet_names
89
+ finally:
90
+ out.unlink()
91
+
92
+
93
+ def test_excel_report_rejects_no_results():
94
+ """Must fail with a clear error if no analysis results exist."""
95
+ # Create a locked plan first so it passes the plan check
96
+ from core.masking.gate import seal_outcomes
97
+ seal_outcomes(STUDY_ID)
98
+ plan = StudyPlan(study_id=STUDY_ID, study_type="cohort", primary_comparison="test",
99
+ planned_tests=[{"variable_name": "response", "test_name": "chi_square"}])
100
+ lock_plan(STUDY_ID, plan)
101
+ out = Path(tempfile.mkstemp(suffix=".xlsx")[1])
102
+ with pytest.raises(RuntimeError, match="No analysis results"):
103
+ generate_excel_report(STUDY_ID, output_path=out)
104
+ out.unlink()
105
+
106
+
107
+ def test_excel_cox_ph_covariate_alignment():
108
+ """B13 regression: Cox PH covariate rows must align under Col 4 (Variable), Col 6 (HR), Col 7 (P-Value), Col 9/10 (CI)."""
109
+ plan = StudyPlan(
110
+ study_id=STUDY_ID,
111
+ study_type="cohort",
112
+ primary_comparison="test comparison",
113
+ planned_tests=[{"variable_name": "age", "test_name": "cox_ph_model"}],
114
+ )
115
+ lock_plan(STUDY_ID, plan)
116
+ conn = get_connection(STUDY_ID)
117
+ init_db(conn)
118
+ conn.execute(
119
+ "INSERT INTO analysis_results (id, study_id, test_name, statistic, p_value, status_json, "
120
+ "sample_counts_json, is_pre_registered, computed_at) "
121
+ "VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)",
122
+ (1, STUDY_ID, "cox_ph_model", 3.45, 0.15,
123
+ json.dumps({"status": "completed"}),
124
+ json.dumps({"n_total": 2, "n_analyzed": 2, "n_excluded": 0}),
125
+ "2025-01-01T00:00:00"),
126
+ )
127
+ conn.execute(
128
+ "INSERT INTO analysis_covariate_results (result_id, covariate, hr, ci_lower, ci_upper, wald_p) "
129
+ "VALUES (?, ?, ?, ?, ?, ?)",
130
+ (1, "age", 1.05, 0.98, 1.12, 0.1234),
131
+ )
132
+ conn.commit()
133
+ conn.close()
134
+
135
+ out = Path(tempfile.mkstemp(suffix=".xlsx")[1])
136
+ try:
137
+ generate_excel_report(STUDY_ID, output_path=out)
138
+ import openpyxl
139
+ wb = openpyxl.load_workbook(str(out))
140
+ ws = wb["Statistical Analyses"]
141
+
142
+ # Row 1 is parent header
143
+ assert ws.cell(1, 4).value == "Variable"
144
+ assert ws.cell(1, 6).value == "Statistic"
145
+ assert ws.cell(1, 7).value == "P-Value"
146
+ assert ws.cell(1, 9).value == "95% CI (Lower)"
147
+ assert ws.cell(1, 10).value == "95% CI (Upper)"
148
+
149
+ # Row 3 is sub-header for Cox PH covariates
150
+ assert ws.cell(3, 4).value == "Covariate"
151
+ assert ws.cell(3, 6).value == "HR"
152
+ assert ws.cell(3, 7).value == "P-Value"
153
+ assert ws.cell(3, 9).value == "CI Lower"
154
+ assert ws.cell(3, 10).value == "CI Upper"
155
+
156
+ # Row 4 is the covariate data row
157
+ assert ws.cell(4, 4).value == "age"
158
+ assert ws.cell(4, 6).value == "1.0500"
159
+ assert ws.cell(4, 7).value == "0.1234"
160
+ assert ws.cell(4, 9).value == "0.9800"
161
+ assert ws.cell(4, 10).value == "1.1200"
162
+ finally:
163
+ out.unlink()
164
+
@@ -0,0 +1,244 @@
1
+ """Tests for the flowchart module."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from core.database import DATA_ROOT, get_connection, init_db
10
+ from core.ingestion.csv_loader import load_file
11
+ from core.reporting.flowchart import (
12
+ FlowStage,
13
+ FlowchartData,
14
+ load_flowchart_data,
15
+ render_ascii,
16
+ render_svg,
17
+ )
18
+
19
+
20
+ def _make_study(study_id: str, with_duplicates: bool = False,
21
+ with_nan_covariate: bool = False):
22
+ study_dir = DATA_ROOT / study_id
23
+ if study_dir.exists():
24
+ shutil.rmtree(study_dir)
25
+ study_dir.mkdir(parents=True, exist_ok=True)
26
+
27
+ conn = get_connection(study_id)
28
+ init_db(conn)
29
+ conn.execute(
30
+ """INSERT INTO studies (id, name, created_at, data_dir, is_locked, unmasked_at)
31
+ VALUES (?, ?, ?, ?, ?, ?)""",
32
+ (study_id, "test", "2026-01-01T00:00:00+00:00", str(study_dir), 2, "2026-07-01T00:00:00+00:00"),
33
+ )
34
+ conn.commit()
35
+ conn.close()
36
+
37
+ import pandas as pd
38
+ if with_duplicates:
39
+ df = pd.DataFrame({
40
+ "patient_id": ["P001", "P002", "P001", "P003", "P004"],
41
+ "age": [50, 55, 50, 62, 58],
42
+ "treatment_arm": ["A", "B", "A", "A", "B"],
43
+ "pfs_days": [150, 280, 180, 320, 240],
44
+ "pfs_event": [1, 0, 1, 1, 0],
45
+ })
46
+ elif with_nan_covariate:
47
+ # One row in arm B has NaN age — Cox PH excludes it
48
+ df = pd.DataFrame({
49
+ "patient_id": ["P001", "P002", "P003", "P004", "P005", "P006"],
50
+ "age": [45, None, 58, 63, 70, 48],
51
+ "treatment_arm": ["A", "B", "A", "B", "A", "B"],
52
+ "pfs_days": [120, 250, 180, 350, 400, 220],
53
+ "pfs_event": [1, 0, 1, 1, 0, 1],
54
+ })
55
+ else:
56
+ df = pd.DataFrame({
57
+ "patient_id": ["P001", "P002", "P003", "P004", "P005", "P006", "P007", "P008"],
58
+ "age": [45, 52, 58, 63, 70, 48, 55, 68],
59
+ "treatment_arm": ["A", "B", "A", "B", "A", "B", "A", "B"],
60
+ "pfs_days": [120, 250, 180, 350, 400, 220, 150, 300],
61
+ "pfs_event": [1, 0, 1, 1, 0, 1, 0, 1],
62
+ })
63
+
64
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
65
+ df.to_csv(f.name, index=False)
66
+ csv_path = f.name
67
+
68
+ load_file(study_id, csv_path)
69
+
70
+ # Write a minimal plan with cox model
71
+ plan = {
72
+ "study_id": study_id,
73
+ "version": 1,
74
+ "locked_at": "2026-07-01T00:00:00+00:00",
75
+ "primary_comparison": "treatment_arm",
76
+ "cox_ph_models": [{
77
+ "model_name": "pfs_multivariable",
78
+ "survival_time_col": "pfs_days",
79
+ "event_col": "pfs_event",
80
+ "primary_treatment_col": "treatment_arm",
81
+ "covariate_cols": ["age"],
82
+ }],
83
+ "warnings": {},
84
+ }
85
+ (DATA_ROOT / study_id / "study_plan.v1.locked.json").write_text(json.dumps(plan))
86
+
87
+ # Run a Cox PH model to populate analysis_results
88
+ from core.stats.inferential import run_test
89
+ conn = get_connection(study_id)
90
+ raw = f"raw_{study_id}"
91
+ df = pd.read_sql(f"SELECT * FROM {raw}", conn)
92
+ conn.close()
93
+
94
+ run_test("cox_ph_model", df,
95
+ outcome_col="pfs_days", group_col="treatment_arm",
96
+ time_col="pfs_days", event_col="pfs_event",
97
+ covariates=["age"])
98
+
99
+ return study_id
100
+
101
+
102
+ class TestFlowchart:
103
+ def setup_method(self):
104
+ self.sid_clean = "test_flowchart_clean"
105
+ self.sid_dupes = "test_flowchart_dupes"
106
+ self.sid_nan_cov = "test_flowchart_nan_cov"
107
+ _make_study(self.sid_clean, with_duplicates=False)
108
+ _make_study(self.sid_dupes, with_duplicates=True)
109
+ _make_study(self.sid_nan_cov, with_nan_covariate=True)
110
+
111
+ def teardown_method(self):
112
+ for sid in [self.sid_clean, self.sid_dupes, self.sid_nan_cov]:
113
+ shutil.rmtree(DATA_ROOT / sid, ignore_errors=True)
114
+
115
+ def test_clean_study_loads_stages(self):
116
+ """Clean study loads all 6 stages with correct counts."""
117
+ data = load_flowchart_data(self.sid_clean)
118
+ assert len(data.stages) == 6
119
+ assert data.stages[0].name == "Assessed for eligibility"
120
+ assert data.stages[0].total == 8
121
+ assert data.stages[0].excluded == 0
122
+ assert data.stages[1].name == "Excluded at ingest (duplicate patient IDs)"
123
+ assert data.stages[1].excluded == 0
124
+ assert data.stages[2].name == "Eligible cohort" # internal name, SVG collapses it
125
+ assert data.stages[3].name == "Allocated to each arm/group"
126
+ assert "A" in data.stages[3].details.get("arm_counts", {})
127
+ assert data.stages[4].name.startswith("Analyzed")
128
+ assert data.stages[5].name.startswith("Final analyzed")
129
+
130
+ def test_duplicate_study_shows_excluded(self):
131
+ """Study with duplicates shows exclusion at ingest stage."""
132
+ data = load_flowchart_data(self.sid_dupes)
133
+ assert len(data.stages) >= 2
134
+ ingest_stage = data.stages[1]
135
+ assert ingest_stage.excluded == 1
136
+ assert ingest_stage.remaining == 4 # 5 total - 1 duplicate = 4 remaining
137
+ assert "Duplicates are retained" in (ingest_stage.note or "")
138
+
139
+ def test_ascii_renders_all_stages(self):
140
+ """ASCII renderer produces output for all stages."""
141
+ data = load_flowchart_data(self.sid_clean)
142
+ text = render_ascii(data)
143
+ lines = text.splitlines()
144
+ assert "Assessed for eligibility" in text
145
+ assert "Excluded at ingest" in text
146
+ assert "Eligible cohort" in text
147
+ assert "Allocated to each arm" in text
148
+ assert "Analyzed" in text
149
+ assert "Final analyzed" in text
150
+ assert "N = 8" in text
151
+
152
+ def test_svg_renders(self):
153
+ """SVG renderer produces valid output with new layout."""
154
+ data = load_flowchart_data(self.sid_clean)
155
+ out = "/tmp/test_flowchart.svg"
156
+ render_svg(data, out, show_title=True, show_watermark=False)
157
+ svg = Path(out).read_text()
158
+ assert "STROBE Participant Flow Diagram" in svg
159
+ assert "Assessed for eligibility" in svg
160
+ assert "Eligible cohort" not in svg # collapsed into enrollment
161
+ assert "Excluded" in svg
162
+ assert "Duplicate patient ID (n = 0)" in svg
163
+ assert "A (n = 4)" in svg # raw arm label, no "Arm " prefix
164
+ assert "B (n = 4)" in svg
165
+ assert "Primary Endpoints (n = 4)" in svg # two-line analyzed subtext
166
+ assert "Final analyzed set" not in svg
167
+ assert "Generated by" not in svg
168
+ assert "test" not in svg
169
+ Path(out).unlink(missing_ok=True)
170
+
171
+ def test_svg_with_study_name(self):
172
+ """Study name subtitle is opt-in via show_study_name."""
173
+ data = load_flowchart_data(self.sid_clean)
174
+ out = "/tmp/test_flowchart_studyname.svg"
175
+ render_svg(data, out, show_title=True, show_study_name=True)
176
+ svg = Path(out).read_text()
177
+ assert "test" in svg # dataset name in subtitle
178
+ Path(out).unlink(missing_ok=True)
179
+
180
+ def test_svg_with_watermark(self):
181
+ """SVG watermark is opt-in."""
182
+ data = load_flowchart_data(self.sid_clean)
183
+ out = "/tmp/test_flowchart_wm.svg"
184
+ render_svg(data, out, show_title=True, show_watermark=True)
185
+ svg = Path(out).read_text()
186
+ assert "Generated by" in svg
187
+ Path(out).unlink(missing_ok=True)
188
+
189
+ def test_svg_no_title(self):
190
+ """SVG title is omittable."""
191
+ data = load_flowchart_data(self.sid_clean)
192
+ out = "/tmp/test_flowchart_notitle.svg"
193
+ render_svg(data, out, show_title=False)
194
+ svg = Path(out).read_text()
195
+ assert "CONSORT Participant Flow Diagram" not in svg
196
+ Path(out).unlink(missing_ok=True)
197
+
198
+ def test_exclusion_with_nan_covariate(self):
199
+ """When a row has missing covariate, analyzed < allocated per arm, both boxes render."""
200
+ data = load_flowchart_data(self.sid_nan_cov)
201
+ assert sum(data.arm_counts.values()) == 6
202
+ arm_analyzed = data.arm_analyzed_counts
203
+ assert arm_analyzed.get("A") == 3, f"Arm A analyzed is {arm_analyzed.get('A')}"
204
+ assert arm_analyzed.get("B") == 2, f"Arm B analyzed is {arm_analyzed.get('B')}"
205
+ assert sum(arm_analyzed.values()) == 5
206
+
207
+ out = "/tmp/test_flowchart_nan.svg"
208
+ render_svg(data, out, show_title=True)
209
+ svg = Path(out).read_text()
210
+ assert "Primary Endpoints (n = 3)" in svg
211
+ assert "Primary Endpoints (n = 2)" in svg
212
+ assert "Final analyzed set" in svg
213
+ Path(out).unlink(missing_ok=True)
214
+
215
+ def test_per_arm_analyzed_counts(self):
216
+ """Per-arm analyzed counts sum to total and are not both equal to total."""
217
+ data = load_flowchart_data(self.sid_clean)
218
+ arm_col = data.arm_column
219
+ assert arm_col is not None
220
+ total_analyzed = data.stages[4].remaining # global analyzed_n
221
+ per_arm = data.arm_analyzed_counts
222
+ assert sum(per_arm.values()) == total_analyzed, (
223
+ f"per-arm sum {sum(per_arm.values())} != total {total_analyzed}"
224
+ )
225
+ for arm, cnt in per_arm.items():
226
+ assert cnt != total_analyzed, (
227
+ f"Arm {arm} analyzed count {cnt} equals total {total_analyzed} — not filtered by arm"
228
+ )
229
+ svg_out = "/tmp/test_flowchart_armcheck.svg"
230
+ render_svg(data, svg_out, show_title=True)
231
+ svg = Path(svg_out).read_text()
232
+ for arm, cnt in per_arm.items():
233
+ assert f"n = {cnt}" in svg, (
234
+ f"SVG should contain 'n = {cnt}' for Arm {arm}'s analyzed box"
235
+ )
236
+ Path(svg_out).unlink(missing_ok=True)
237
+
238
+ def test_arm_counts_match_plan(self):
239
+ """Arm counts in flowchart match plan's treatment arm column."""
240
+ data = load_flowchart_data(self.sid_clean)
241
+ arm_stage = data.stages[3]
242
+ assert arm_stage.details.get("arm_counts") is not None
243
+ total_arms = sum(arm_stage.details["arm_counts"].values())
244
+ assert total_arms == 8 # clean study has 8 patients