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.
- app/backend/_bootstrap.py +15 -0
- app/backend/exports/verification_script.py +19 -0
- app/backend/main.py +59 -0
- app/backend/routers/__init__.py +1 -0
- app/backend/routers/execution.py +478 -0
- app/backend/routers/ingestion.py +233 -0
- app/backend/routers/planning.py +265 -0
- app/backend/routers/reporting.py +531 -0
- app/backend/state.py +44 -0
- core/__init__.py +0 -0
- core/cli/__init__.py +0 -0
- core/cli/main.py +1705 -0
- core/database.py +62 -0
- core/ingestion/__init__.py +0 -0
- core/ingestion/csv_loader.py +191 -0
- core/ingestion/variable_classifier.py +171 -0
- core/masking/__init__.py +0 -0
- core/masking/gate.py +128 -0
- core/models.py +138 -0
- core/planning/__init__.py +0 -0
- core/planning/diagnostics.py +89 -0
- core/planning/lock.py +232 -0
- core/planning/study_plan.py +73 -0
- core/planning/test_selector.py +518 -0
- core/provenance/__init__.py +0 -0
- core/provenance/hashing.py +38 -0
- core/provenance/tracker.py +105 -0
- core/reporting/__init__.py +62 -0
- core/reporting/appendix.py +58 -0
- core/reporting/bundle.py +378 -0
- core/reporting/excel_export.py +683 -0
- core/reporting/flowchart/__init__.py +20 -0
- core/reporting/flowchart/flowchart.py +511 -0
- core/reporting/forensics.py +592 -0
- core/reporting/forest_plot.py +614 -0
- core/reporting/lineage.py +562 -0
- core/reporting/manuscript_draft.py +726 -0
- core/reporting/plots.py +568 -0
- core/reporting/strobe_checklist.py +460 -0
- core/stats/__init__.py +0 -0
- core/stats/descriptive.py +104 -0
- core/stats/inferential.py +540 -0
- core/stats/multiple_comparisons.py +62 -0
- core/stats/post_hoc.py +62 -0
- exports/verification_script.py +19 -0
- research_tool_cli-0.1.0.dist-info/METADATA +16 -0
- research_tool_cli-0.1.0.dist-info/RECORD +93 -0
- research_tool_cli-0.1.0.dist-info/WHEEL +5 -0
- research_tool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- research_tool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- research_tool_cli-0.1.0.dist-info/top_level.txt +4 -0
- tests/__init__.py +0 -0
- tests/conftest.py +45 -0
- tests/test_amendments.py +296 -0
- tests/test_analyze_batch_robustness.py +162 -0
- tests/test_app_statistical_reporting.py +383 -0
- tests/test_benchmark_21.py +556 -0
- tests/test_bundle.py +277 -0
- tests/test_cox_ph_plan.py +498 -0
- tests/test_csv_loader.py +368 -0
- tests/test_end_to_end.py +302 -0
- tests/test_excel_export.py +164 -0
- tests/test_flowchart.py +244 -0
- tests/test_forensics.py +305 -0
- tests/test_forest_plot.py +374 -0
- tests/test_from_json.py +176 -0
- tests/test_latest_plan_version.py +164 -0
- tests/test_lineage.py +329 -0
- tests/test_lock_immutability.py +133 -0
- tests/test_m1_fk_violation.py +85 -0
- tests/test_m2_data_hash_consistency.py +40 -0
- tests/test_m3_correction_timing.py +59 -0
- tests/test_m4_dedup_field.py +59 -0
- tests/test_m5_excel_hash_scope.py +45 -0
- tests/test_m6_fisher_exact_naming.py +44 -0
- tests/test_m7_duplicate_study_plan.py +55 -0
- tests/test_m8_filter_superseded.py +58 -0
- tests/test_m9_table1_groupby.py +56 -0
- tests/test_manuscript_draft.py +289 -0
- tests/test_masking_gate.py +196 -0
- tests/test_masking_migration.py +111 -0
- tests/test_multiple_comparisons.py +56 -0
- tests/test_plan_validation.py +289 -0
- tests/test_plots.py +394 -0
- tests/test_post_hoc_tagging.py +49 -0
- tests/test_posthoc_analyze.py +203 -0
- tests/test_provenance_tracker.py +110 -0
- tests/test_roadmap_features.py +148 -0
- tests/test_stats_descriptive.py +57 -0
- tests/test_stats_inferential.py +380 -0
- tests/test_strobe_checklist.py +172 -0
- tests/test_test_selector.py +350 -0
- 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
|
+
|
tests/test_flowchart.py
ADDED
|
@@ -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
|