302 lines
11 KiB
Python
302 lines
11 KiB
Python
from pathlib import Path
|
||
import subprocess
|
||
import sys
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from apps.cli.run_rnaseq_de import build_de_plan, generate_modular_r_script, run_deseq2
|
||
from apps.cli.run_rnaseq_fgsea import find_de_results
|
||
from apps.cli.run_samples_autofill import design_from_samples
|
||
from gfa_core.rnaseq_de_design import (
|
||
build_de_design_review,
|
||
plan_rnaseq_de_design,
|
||
)
|
||
|
||
|
||
def _factorial(*, drop=(), time_values=None):
|
||
samples = {}
|
||
for genotype in ("G1", "G2"):
|
||
for condition, role in (("Control", "control"), ("Salt", "compare")):
|
||
for replicate in (1, 2):
|
||
sample = f"{genotype}_{condition}_{replicate}"
|
||
if sample in drop:
|
||
continue
|
||
row = {
|
||
"condition": condition,
|
||
"genotype": genotype,
|
||
"group_role": role,
|
||
"replicate": replicate,
|
||
"include": True,
|
||
}
|
||
if time_values:
|
||
row["time"] = time_values[replicate - 1]
|
||
samples[sample] = row
|
||
return samples
|
||
|
||
|
||
def _plan(samples, module=""):
|
||
matrix_samples = list(samples)
|
||
groups = {sample: row["condition"] for sample, row in samples.items()}
|
||
return build_de_plan({"samples": samples, "de": {"strategy": "auto"}}, matrix_samples, groups, user_module=module)
|
||
|
||
|
||
def test_complete_factorial_selects_m4_and_keeps_m3_simple_effects():
|
||
plan = _plan(_factorial())
|
||
assert plan["module"] == "M4"
|
||
assert plan["advanced_factor"] == "genotype"
|
||
assert plan["comparisons"] == [
|
||
["G1__Salt", "G1__Control"],
|
||
["G2__Salt", "G2__Control"],
|
||
["G2__Control", "G1__Control"],
|
||
["G2__Salt", "G1__Salt"],
|
||
]
|
||
assert plan["comparison_axis"] == "condition"
|
||
assert plan["reference_condition"] == "Control"
|
||
assert plan["reference_factor"] == "G1"
|
||
assert plan["interaction_full"] == "~ genotype * condition"
|
||
assert plan["fallback_from"] == ""
|
||
|
||
|
||
def test_incomplete_factorial_degrades_to_valid_m3_strata():
|
||
samples = _factorial(drop={"G2_Salt_1", "G2_Salt_2"})
|
||
plan = _plan(samples)
|
||
assert plan["module"] == "M3"
|
||
assert plan["comparisons"] == [["G1__Salt", "G1__Control"]]
|
||
assert plan["fallback_from"] == "M4"
|
||
assert plan["skipped_comparisons"]
|
||
|
||
|
||
def test_varying_time_is_not_silently_ignored_by_m3_m4():
|
||
with pytest.raises(ValueError, match="time"):
|
||
_plan(_factorial(time_values=("0h", "6h")))
|
||
|
||
|
||
def test_m4_script_emits_group_contrasts_and_unsigned_lrt(tmp_path):
|
||
samples = _factorial()
|
||
plan = _plan(samples)
|
||
script = generate_modular_r_script(
|
||
tmp_path / "counts.txt",
|
||
list(samples),
|
||
plan,
|
||
tmp_path,
|
||
0.05,
|
||
1.0,
|
||
)
|
||
assert "dds_group" in script
|
||
assert 'test="LRT"' in script
|
||
assert "reduced=~ genotype + condition" in script
|
||
assert "genotype_by_condition.interaction_LRT.DE_results.txt" in script
|
||
assert "dds_wald" in script
|
||
assert "interaction_Wald" in script
|
||
assert "falling back to ~condition" not in script
|
||
|
||
|
||
def test_group_role_may_define_genotype_axis_in_standard_factorial():
|
||
samples = {}
|
||
for genotype, role in (("WT", "control"), ("ire1dm", "compare")):
|
||
for condition in ("DMSO", "Tm"):
|
||
for replicate in (1, 2):
|
||
sample = f"{genotype}_{condition}_{replicate}"
|
||
samples[sample] = {
|
||
"genotype": genotype,
|
||
"condition": condition,
|
||
"group_role": role,
|
||
"replicate": replicate,
|
||
"include": True,
|
||
}
|
||
plan = _plan(samples)
|
||
assert plan["module"] == "M4"
|
||
assert plan["comparison_axis"] == "genotype"
|
||
assert plan["stratify_axis"] == "condition"
|
||
assert plan["reference_factor"] == "WT"
|
||
assert plan["reference_condition"] == "DMSO"
|
||
assert plan["comparisons"] == [
|
||
["ire1dm__DMSO", "WT__DMSO"],
|
||
["ire1dm__Tm", "WT__Tm"],
|
||
["WT__Tm", "WT__DMSO"],
|
||
["ire1dm__Tm", "ire1dm__DMSO"],
|
||
]
|
||
|
||
|
||
def test_blank_roles_use_common_reference_names_without_rejecting_m4():
|
||
samples = {}
|
||
for genotype in ("WT", "ire1dm"):
|
||
for condition in ("DMSO", "Tm"):
|
||
for replicate in (1, 2):
|
||
sample = f"{genotype}_{condition}_{replicate}"
|
||
samples[sample] = {
|
||
"genotype": genotype,
|
||
"condition": condition,
|
||
"group_role": "",
|
||
"replicate": replicate,
|
||
"include": True,
|
||
}
|
||
plan = _plan(samples)
|
||
assert plan["module"] == "M4"
|
||
assert plan["reference_factor"] == "WT"
|
||
assert plan["reference_condition"] == "DMSO"
|
||
assert plan["factor_reference_source"] == "semantic"
|
||
assert plan["condition_reference_source"] == "semantic"
|
||
|
||
|
||
def test_fgsea_excludes_unsigned_m4_lrt(tmp_path):
|
||
directional = tmp_path / "Os.rnaseq_de.G1__Salt_vs_G1__Control.DE_results.txt"
|
||
interaction = tmp_path / "Os.rnaseq_de.genotype_by_condition.interaction_LRT.DE_results.txt"
|
||
directional.write_text("gene_id\tstat\n", encoding="utf-8")
|
||
interaction.write_text("gene_id\tstat\n", encoding="utf-8")
|
||
assert find_de_results(tmp_path, "Os") == [directional]
|
||
with pytest.raises(ValueError, match="not eligible"):
|
||
find_de_results(tmp_path, "Os", "genotype_by_condition.interaction_LRT")
|
||
|
||
|
||
def test_frontend_review_matches_executable_m4_plan():
|
||
samples = {}
|
||
for genotype, role in (("WT", "control"), ("ire1dm", "compare")):
|
||
for condition in ("DMSO", "Tm"):
|
||
for replicate in (1, 2):
|
||
sample = f"{genotype}_{condition}_{replicate}"
|
||
samples[sample] = {
|
||
"genotype": genotype,
|
||
"condition": condition,
|
||
"group_role": role,
|
||
"replicate": replicate,
|
||
"include": True,
|
||
}
|
||
plan = plan_rnaseq_de_design(samples, matrix_samples=list(samples))
|
||
review = build_de_design_review(plan)
|
||
assert review["module"] == "M4"
|
||
assert review["comparison_axis"] == "genotype"
|
||
assert [item["display"] for item in review["comparisons"]] == [
|
||
"DMSO 条件下:ire1dm vs WT",
|
||
"Tm 条件下:ire1dm vs WT",
|
||
"WT 中:Tm vs DMSO",
|
||
"ire1dm 中:Tm vs DMSO",
|
||
]
|
||
assert review["interaction"] == {
|
||
"enabled": True,
|
||
"display": "genotype × condition 交互效应",
|
||
"methods": ["LRT", "Wald"],
|
||
}
|
||
|
||
|
||
def test_saved_module_override_is_used_by_de_execution():
|
||
samples = _factorial()
|
||
groups = {sample: row["condition"] for sample, row in samples.items()}
|
||
plan = build_de_plan(
|
||
{"samples": samples, "de": {"strategy": "auto", "module": "M3"}},
|
||
list(samples),
|
||
groups,
|
||
)
|
||
assert plan["module"] == "M3"
|
||
assert plan["fallback_from"] == "M4"
|
||
|
||
|
||
def test_optional_batch_is_added_to_m1_and_frontend_review():
|
||
samples = {}
|
||
for condition, role in (("Control", "control"), ("Salt", "compare")):
|
||
for replicate, batch in ((1, "B1"), (2, "B2")):
|
||
sample = f"{condition}_{replicate}"
|
||
samples[sample] = {
|
||
"condition": condition,
|
||
"group_role": role,
|
||
"replicate": replicate,
|
||
"batch": batch,
|
||
"include": True,
|
||
}
|
||
plan = plan_rnaseq_de_design(samples, matrix_samples=list(samples))
|
||
review = build_de_design_review(plan)
|
||
assert plan["module"] == "M1"
|
||
assert plan["batch_requested"] is True
|
||
assert plan["batch_used"] is True
|
||
assert plan["design_formula"] == "~ batch + group"
|
||
assert review["batch"]["used"] is True
|
||
assert review["batch"]["levels"] == ["B1", "B2"]
|
||
|
||
|
||
def test_incomplete_batch_falls_back_without_blocking_design():
|
||
samples = {
|
||
"Control_1": {"condition": "Control", "group_role": "control", "batch": "B1"},
|
||
"Control_2": {"condition": "Control", "group_role": "control", "batch": ""},
|
||
"Salt_1": {"condition": "Salt", "group_role": "compare", "batch": "B1"},
|
||
"Salt_2": {"condition": "Salt", "group_role": "compare", "batch": ""},
|
||
}
|
||
plan = plan_rnaseq_de_design(samples, matrix_samples=list(samples))
|
||
review = build_de_design_review(plan)
|
||
assert plan["module"] == "M1"
|
||
assert plan["batch_requested"] is True
|
||
assert plan["batch_used"] is False
|
||
assert plan["batch_fallback"] is True
|
||
assert plan["design_formula"] == "~ group"
|
||
assert review["batch"]["fallback"] is True
|
||
|
||
|
||
def test_optional_batch_is_used_by_m4_script(tmp_path):
|
||
samples = _factorial()
|
||
for sample, row in samples.items():
|
||
row["batch"] = "B1" if sample.endswith("_1") else "B2"
|
||
plan = _plan(samples)
|
||
script = generate_modular_r_script(
|
||
tmp_path / "counts.txt",
|
||
list(samples),
|
||
plan,
|
||
tmp_path,
|
||
0.05,
|
||
1.0,
|
||
)
|
||
assert plan["batch_used"] is True
|
||
assert plan["design_formula"] == "~ batch + genotype * condition"
|
||
assert "batch = factor(batch_vals" in script
|
||
assert "design=~ batch + genotype * condition" in script
|
||
assert "reduced=~ batch + genotype + condition" in script
|
||
|
||
|
||
def test_batch_runtime_failure_retries_once_without_batch(tmp_path, monkeypatch):
|
||
samples = {}
|
||
for condition, role in (("Control", "control"), ("Salt", "compare")):
|
||
for replicate, batch in ((1, "B1"), (2, "B2")):
|
||
sample = f"{condition}_{replicate}"
|
||
samples[sample] = {
|
||
"condition": condition,
|
||
"group_role": role,
|
||
"replicate": replicate,
|
||
"batch": batch,
|
||
"include": True,
|
||
}
|
||
config = tmp_path / "samples.yaml"
|
||
config.write_text(yaml.safe_dump({"samples": samples}), encoding="utf-8")
|
||
count_matrix = tmp_path / "counts.txt"
|
||
count_matrix.write_text(
|
||
"gene_id\tControl_1\tControl_2\tSalt_1\tSalt_2\n"
|
||
"gene1\t10\t11\t30\t31\n"
|
||
"gene2\t20\t19\t5\t6\n",
|
||
encoding="utf-8",
|
||
)
|
||
output_dir = tmp_path / "de"
|
||
output_dir.mkdir()
|
||
calls = []
|
||
|
||
def fake_rscript(*args, **kwargs):
|
||
calls.append(args)
|
||
if len(calls) == 1:
|
||
raise subprocess.CalledProcessError(
|
||
1,
|
||
args[0],
|
||
stderr="model matrix is not full rank",
|
||
)
|
||
return subprocess.CompletedProcess(args[0], 0, "", "")
|
||
|
||
monkeypatch.setattr("apps.cli.run_rnaseq_de.subprocess.run", fake_rscript)
|
||
status, plan = run_deseq2(count_matrix, config, output_dir)
|
||
assert len(calls) == 2
|
||
assert all(status.values())
|
||
assert plan["batch_requested"] is True
|
||
assert plan["batch_used"] is False
|
||
assert plan["batch_fallback"] is True
|
||
assert (output_dir / "BATCH_FALLBACK.txt").exists()
|
||
assert "batch +" not in (output_dir / "run_deseq2.R").read_text(encoding="utf-8")
|