Files

302 lines
11 KiB
Python
Raw Permalink Normal View History

2026-08-03 18:42:47 +08:00
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")