| """ |
| Tests for the decoupler_integrate_datasets MCP tool (ADR-0001 T9, Mode A). |
| |
| The tool's value is the routing gate: it pools + runs a batch-aware DE ONLY on a |
| plan verdict of 'early', and refuses/reroutes otherwise. These tests monkeypatch |
| the plan, the combined-AnnData builder, and the DE tool so the routing is checked |
| deterministically with no network or compute. The actual pooling/DE mechanics |
| are covered by test_integration_mode_a.py and test_de_batch_covariate.py. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import sys |
| from pathlib import Path |
|
|
| sys.path.insert(0, str(Path(__file__).parent.parent)) |
|
|
| import src.tools.integration_tools as it |
| from src.tools.integration_tools import ( |
| decoupler_integrate_datasets, |
| integration_mcp, |
| ) |
|
|
|
|
| def _plan(mode, **extra): |
| base = {"mode": mode, "reason": f"{mode} reason.", "refusal_rules_triggered": []} |
| base.update(extra) |
| return lambda *a, **k: base |
|
|
|
|
| def test_tool_registered(): |
| names = {t.name for t in asyncio.run(integration_mcp.list_tools())} |
| assert "decoupler_integrate_datasets" in names |
|
|
|
|
| def test_arity_guard(): |
| out = decoupler_integrate_datasets( |
| dataset_ids=["solo"], design_factor="g", test_group="t", control_group="c" |
| ) |
| assert out["refused"] is True |
| assert ">=2" in out["error"] |
|
|
|
|
| def test_late_reroutes_to_meta_analyze(monkeypatch): |
| monkeypatch.setattr(it, "get_integration_plan", _plan("late")) |
| out = decoupler_integrate_datasets( |
| dataset_ids=["a", "b"], design_factor="g", test_group="t", control_group="c" |
| ) |
| assert out["refused"] and out["mode"] == "late" |
| assert "decoupler_meta_analyze" in out["error"] |
|
|
|
|
| def test_concordance_reroutes(monkeypatch): |
| monkeypatch.setattr(it, "get_integration_plan", _plan("concordance")) |
| out = decoupler_integrate_datasets( |
| dataset_ids=["a", "b"], design_factor="g", test_group="t", control_group="c" |
| ) |
| assert out["refused"] and out["mode"] == "concordance" |
| assert "decoupler_normalization_concordance" in out["error"] |
|
|
|
|
| def test_refuse_passes_reason_and_rules(monkeypatch): |
| monkeypatch.setattr( |
| it, |
| "get_integration_plan", |
| _plan( |
| "refuse", reason="CONFOUNDED_DESIGN: ...", refusal_rules_triggered=["CONFOUNDED_DESIGN"] |
| ), |
| ) |
| out = decoupler_integrate_datasets( |
| dataset_ids=["a", "b"], design_factor="g", test_group="t", control_group="c" |
| ) |
| assert out["refused"] and out["mode"] == "refuse" |
| assert "CONFOUNDED_DESIGN" in out["error"] |
| assert out["refusal_rules_triggered"] == ["CONFOUNDED_DESIGN"] |
|
|
|
|
| def test_plan_exception_refuses(monkeypatch): |
| def boom(*a, **k): |
| raise ValueError("dataset 'nope' not registered") |
|
|
| monkeypatch.setattr(it, "get_integration_plan", boom) |
| out = decoupler_integrate_datasets( |
| dataset_ids=["a", "nope"], design_factor="g", test_group="t", control_group="c" |
| ) |
| assert out["refused"] and "not registered" in out["error"] |
|
|
|
|
| def test_early_builds_and_runs_batch_aware_de(monkeypatch, tmp_path): |
| monkeypatch.setattr( |
| it, "get_integration_plan", _plan("early", poolable_data_level="raw_counts") |
| ) |
| built = { |
| "output_path": str(tmp_path / "combined.h5ad"), |
| "n_obs": 42, |
| "n_vars": 1500, |
| "per_batch_n": {"a": 20, "b": 22}, |
| "dataset_ids": ["a", "b"], |
| } |
| monkeypatch.setattr(it, "build_combined_anndata", lambda *a, **k: built) |
| captured = {} |
|
|
| def fake_de(**kwargs): |
| captured.update(kwargs) |
| return { |
| "method_used": "DESeq2", |
| "n_significant": 7, |
| "output_path": str(tmp_path / "de.csv"), |
| "sanity_warnings": {"n_warnings": 0}, |
| } |
|
|
| monkeypatch.setattr(it, "decoupler_differential_expression", fake_de) |
| out = decoupler_integrate_datasets( |
| dataset_ids=["a", "b"], |
| design_factor="subtype", |
| test_group="basal", |
| control_group="classical", |
| ) |
| assert out["mode"] == "early" and out["batch_modeled"] is True |
| assert out["n_significant"] == 7 and out["method_used"] == "DESeq2" |
| assert out["per_batch_n"] == {"a": 20, "b": 22} |
| |
| assert captured["method"] == "deseq2" |
| assert captured["batch_column"] == "batch" |
| assert captured["contrast"] == ["subtype", "basal", "classical"] |
| assert captured["adata_path"] == built["output_path"] |
|
|
|
|
| def test_early_auto_method_limma_for_non_raw(monkeypatch, tmp_path): |
| monkeypatch.setattr( |
| it, "get_integration_plan", _plan("early", poolable_data_level="log_expression") |
| ) |
| monkeypatch.setattr( |
| it, |
| "build_combined_anndata", |
| lambda *a, **k: { |
| "output_path": str(tmp_path / "c.h5ad"), |
| "n_obs": 10, |
| "n_vars": 900, |
| "per_batch_n": {"a": 5, "b": 5}, |
| }, |
| ) |
| captured = {} |
|
|
| def fake_de(**kwargs): |
| captured.update(kwargs) |
| return {"method_used": "limma", "n_significant": 3, "output_path": str(tmp_path / "de.csv")} |
|
|
| monkeypatch.setattr(it, "decoupler_differential_expression", fake_de) |
| decoupler_integrate_datasets( |
| dataset_ids=["a", "b"], design_factor="g", test_group="t", control_group="c" |
| ) |
| assert captured["method"] == "limma" |
|
|
|
|
| def test_early_build_failure_refuses(monkeypatch): |
| monkeypatch.setattr( |
| it, "get_integration_plan", _plan("early", poolable_data_level="raw_counts") |
| ) |
|
|
| def boom(*a, **k): |
| raise ValueError("only 12 shared gene symbols") |
|
|
| monkeypatch.setattr(it, "build_combined_anndata", boom) |
| out = decoupler_integrate_datasets( |
| dataset_ids=["a", "b"], design_factor="g", test_group="t", control_group="c" |
| ) |
| assert out["refused"] and "shared gene symbols" in out["error"] |
|
|