Files
PlantAI-dev/tests/unit/test_gwas_emmax_plot_backend.py
2026-08-03 18:42:47 +08:00

493 lines
23 KiB
Python

import asyncio
import hashlib
import json
import subprocess
from pathlib import Path
from apps.web import pipeline_backend as backend
from apps.web.vue3 import main as api
from gfa_core.mas.stage_capabilities import get_stage_capability
from gfa_core.mas.tools.stage_identity import StageIdentity, get_stage_status_for_identity
from gfa_core.project_paths import get_conda_env_rscript
GENOTYPE = "genotype:" + "a" * 64
PHENOTYPE = "phenotype:" + "b" * 64
SOURCE_MODEL = "gwas-model:" + "c" * 64
ANALYSIS = "gwas-analysis:" + "d" * 64
RSCRIPT = str(get_conda_env_rscript("ASAW"))
def _sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _artifact(workspace: Path, path: Path, role: str) -> dict:
return {
"role": role, "artifact": role,
"path": str(path.relative_to(workspace)), "full_path": str(path.resolve()),
"size": path.stat().st_size, "sha256": _sha(path),
}
def _emmax_producer(workspace: Path, prefix: str = "cohort") -> tuple[Path, Path]:
final = workspace / "final"
final.mkdir(parents=True, exist_ok=True)
pvalues = final / f"{prefix}.gwas_emmax.identity.emmax_pvalues.tsv"
pvalues.write_text("chr\tpos\tp\n1\t10\t0.01\n", encoding="utf-8")
manifest = {
"manifest_version": "1.0", "run_id": "emmax-run", "stage": "gwas_emmax",
"prefix": prefix, "status": "success", "genotype_scope_id": GENOTYPE,
"phenotype_scope_id": PHENOTYPE, "model_scope_id": SOURCE_MODEL,
"analysis_scope_id": "gwas-analysis:" + "e" * 64,
"metrics": {"emmax_tested_count": 87466},
"artifacts": {"emmax_pvalues": _artifact(workspace, pvalues, "emmax_pvalues")},
}
path = final / f"{prefix}.gwas_emmax.identity.manifest.json"
path.write_text(json.dumps(manifest), encoding="utf-8")
return path, pvalues
def _emplot_manifest(
workspace: Path, prefix: str = "cohort", roles=None, *,
source_model: str = SOURCE_MODEL, analysis: str = ANALYSIS, tag: str = "identity",
) -> Path:
final = workspace / "final"
final.mkdir(parents=True, exist_ok=True)
roles = roles or ("emmax_manhattan", "emmax_qq", "report", "environment")
artifacts = {}
for role in roles:
suffix = ".png" if role.startswith("emmax_") else ".md" if role == "report" else ".txt"
path = final / f"{prefix}.gwas_emplot.{tag}.{role}{suffix}"
path.write_bytes(b"png" if suffix == ".png" else f"{role}\n".encode())
artifacts[role] = _artifact(workspace, path, role)
manifest = {
"manifest_version": "1.0", "stage": "gwas_emplot", "prefix": prefix,
"status": "success", "genotype_scope_id": GENOTYPE,
"phenotype_scope_id": PHENOTYPE, "source_model_scope_id": source_model,
"analysis_scope_id": analysis, "elapsed_sec": 3.26, "artifacts": artifacts,
}
path = final / f"{prefix}.gwas_emplot.{tag}.manifest.json"
path.write_text(json.dumps(manifest), encoding="utf-8")
return path
def _scopes() -> dict:
return {
"genotypeScopeId": GENOTYPE, "phenotypeScopeId": PHENOTYPE,
"sourceModelScopeId": SOURCE_MODEL,
}
def test_emplot_preflight_materializes_exact_emmax_manifest_and_count(monkeypatch, tmp_path):
workspace = tmp_path / "workspace/job"
manifest, pvalues = _emmax_producer(workspace)
monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace")
result = api._api_semantic_preflight("job", "gwas_emplot", "cohort", _scopes())
assert result["valid"] is True
assert result["materialized_params"] == {
"gwasEmmaxPvalues": str(pvalues), "gwasEmmaxManifest": str(manifest),
"gwasEmmaxTestedHypotheses": 87466, "gwasRscript": RSCRIPT,
**_scopes(),
}
def test_emplot_preflight_materializes_scopes_from_unique_emmax_manifest(
monkeypatch, tmp_path
):
workspace = tmp_path / "workspace/job"
manifest, pvalues = _emmax_producer(workspace)
monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace")
result = api._api_semantic_preflight("job", "gwas_emplot", "cohort", {})
assert result["valid"] is True
assert result["materialized_params"] == {
"gwasEmmaxPvalues": str(pvalues),
"gwasEmmaxManifest": str(manifest),
"gwasEmmaxTestedHypotheses": 87466,
"gwasRscript": RSCRIPT,
**_scopes(),
}
def test_emplot_preflight_rejects_wrong_scope_and_caller_override(monkeypatch, tmp_path):
_emmax_producer(tmp_path / "workspace/job")
monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace")
for params in (
{**_scopes(), "sourceModelScopeId": "gwas-model:" + "9" * 64},
{**_scopes(), "gwasEmmaxPvalues": str(tmp_path / "other.tsv")},
{**_scopes(), "gwasEmmaxTestedHypotheses": 1},
):
result = api._api_semantic_preflight("job", "gwas_emplot", "cohort", params)
assert result["valid"] is False
assert result["issues"][0]["code"] == "invalid_gwas_emplot_upstream"
def test_emplot_command_is_only_canonical_runner(tmp_path):
command = backend._gwas_emplot_cli_command(
workspace=tmp_path, job_id="job", prefix="cohort",
emmax_pvalues=tmp_path / "p.tsv", emmax_manifest=tmp_path / "producer.json",
genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE,
source_model_scope_id=SOURCE_MODEL, emmax_tested_hypotheses=87466,
rscript=RSCRIPT, log_file=tmp_path / "logs/emplot.log",
)
assert command[:3] == [backend.sys.executable, "-m", "apps.cli.run_gwas_emmax_plot"]
for flag, value in {
"--emmax-pvalues": str(tmp_path / "p.tsv"),
"--emmax-manifest": str(tmp_path / "producer.json"),
"--genotype-scope-id": GENOTYPE, "--phenotype-scope-id": PHENOTYPE,
"--source-model-scope-id": SOURCE_MODEL,
"--emmax-tested-hypotheses": "87466", "--rscript": RSCRIPT,
}.items():
assert command.count(flag) == 1 and command[command.index(flag) + 1] == value
assert not any(token.endswith(".sh") or token.endswith(".R") for token in command)
def test_execute_request_and_declarations_match_emplot_contract():
from gfa_core.mas.tools.stage_tools import StageToolRegistry
request = api.ExecuteRequest(
stage="gwas_emplot", gwasEmmaxPvalues="p", gwasEmmaxManifest="m",
gwasEmmaxTestedHypotheses=87466, gwasRscript=RSCRIPT, **_scopes(),
)
assert request.gwasEmmaxTestedHypotheses == 87466
capability = get_stage_capability("gwas_emplot")
assert [item.role for item in capability.consumes] == ["emmax_pvalues"]
assert [item.role for item in capability.provides] == [
"emmax_manhattan", "emmax_qq", "manifest", "report", "environment",
]
tool = StageToolRegistry().get("gwas_emplot")
assert tool.required_inputs == ["emmax_pvalues"]
assert tool.outputs == ["emmax_manhattan", "emmax_qq", "manifest", "report", "environment"]
def test_backend_emplot_accepts_only_fresh_exact_success_manifest(monkeypatch, tmp_path):
workspace = tmp_path / "workspace"
producer, pvalues = _emmax_producer(workspace)
decisions = {
"emmax_pvalues": {"path": str(pvalues), "source_manifest": str(producer)},
"emmax_tested_count": 87466, "source_manifest": str(producer),
}
monkeypatch.setattr(backend, "_resolve_gwas_emplot_inputs", lambda **_kwargs: decisions)
def fake_run(command, cwd=None):
assert command[command.index("--log-file") + 1].endswith("_gwas_emplot.log")
_emplot_manifest(workspace)
return 0
monkeypatch.setattr(backend, "_run_child_owned_log", fake_run)
message, manifest = backend.run_stage(
stage="gwas_emplot",
job=backend.JobInfo("job", str(tmp_path / "input"), str(workspace), str(workspace / "logs"), "now"),
prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE,
source_model_scope_id=SOURCE_MODEL, gwas_rscript=RSCRIPT,
)
assert message.startswith("✅")
assert manifest == workspace / "final/cohort.gwas_emplot.identity.manifest.json"
def test_backend_emplot_failure_never_replays_prior_success(monkeypatch, tmp_path):
workspace = tmp_path / "workspace"
producer, pvalues = _emmax_producer(workspace)
_emplot_manifest(workspace)
monkeypatch.setattr(backend, "_resolve_gwas_emplot_inputs", lambda **_kwargs: {
"emmax_pvalues": {"path": str(pvalues), "source_manifest": str(producer)},
"emmax_tested_count": 87466, "source_manifest": str(producer),
})
monkeypatch.setattr(backend, "_run_child_owned_log", lambda *_args, **_kwargs: 7)
message, manifest = backend.run_stage(
stage="gwas_emplot",
job=backend.JobInfo("job", str(tmp_path / "input"), str(workspace), str(workspace / "logs"), "now"),
prefix="cohort", genotype_scope_id=GENOTYPE, phenotype_scope_id=PHENOTYPE,
source_model_scope_id=SOURCE_MODEL, gwas_rscript=RSCRIPT,
)
assert "exit code: 7" in message and manifest is None
def test_emplot_status_and_results_require_exact_four_scopes(monkeypatch, tmp_path):
workspace = tmp_path / "workspace/job"
_emplot_manifest(workspace)
monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace")
identity = StageIdentity(
stage="gwas_emplot", prefix="cohort", genotype_scope_id=GENOTYPE,
phenotype_scope_id=PHENOTYPE, source_model_scope_id=SOURCE_MODEL,
analysis_scope_id=ANALYSIS,
)
assert get_stage_status_for_identity(
"job", identity, workspace_root=tmp_path / "workspace", strict_identity=True,
).completed is True
exact = asyncio.run(api.get_results(
job_id="job", stage="gwas_emplot", genotype_scope_id=GENOTYPE,
phenotype_scope_id=PHENOTYPE, source_model_scope_id=SOURCE_MODEL,
analysis_scope_id=ANALYSIS,
))
assert len(exact["gwas_emplot"]["images"]) == 2
assert {item["role"] for item in exact["gwas_emplot"]["files"]} == {
"report", "environment", "manifest",
}
assert exact["gwas_emplot"]["elapsed_time"] == 3.3
wrong = asyncio.run(api.get_results(
job_id="job", stage="gwas_emplot", genotype_scope_id=GENOTYPE,
phenotype_scope_id=PHENOTYPE, source_model_scope_id="gwas-model:" + "9" * 64,
analysis_scope_id=ANALYSIS,
))
assert wrong["gwas_emplot"]["images"] == [] and wrong["gwas_emplot"]["files"] == []
def test_emplot_same_prefix_siblings_require_exact_source_model_and_analysis(monkeypatch, tmp_path):
workspace = tmp_path / "workspace/job"
other_model = "gwas-model:" + "8" * 64
other_analysis = "gwas-analysis:" + "7" * 64
_emplot_manifest(workspace, tag="first")
_emplot_manifest(
workspace, source_model=other_model, analysis=other_analysis, tag="second",
)
monkeypatch.setattr(api, "WORKSPACE_ROOT", tmp_path / "workspace")
exact = asyncio.run(api.get_results(
job_id="job", stage="gwas_emplot", genotype_scope_id=GENOTYPE,
phenotype_scope_id=PHENOTYPE, source_model_scope_id=SOURCE_MODEL,
analysis_scope_id=ANALYSIS,
))
sibling = asyncio.run(api.get_results(
job_id="job", stage="gwas_emplot", genotype_scope_id=GENOTYPE,
phenotype_scope_id=PHENOTYPE, source_model_scope_id=other_model,
analysis_scope_id=other_analysis,
))
mixed = asyncio.run(api.get_results(
job_id="job", stage="gwas_emplot", genotype_scope_id=GENOTYPE,
phenotype_scope_id=PHENOTYPE, source_model_scope_id=SOURCE_MODEL,
analysis_scope_id=other_analysis,
))
assert len(exact["gwas_emplot"]["images"]) == 2
assert len(sibling["gwas_emplot"]["images"]) == 2
assert mixed["gwas_emplot"]["images"] == [] and mixed["gwas_emplot"]["files"] == []
def test_api_task_accepts_only_complete_emplot_postflight(monkeypatch, tmp_path):
import gfa_core.mas.resource_governor as resource_governor
workspace_root = tmp_path / "workspace"
captured = {}
class NoopLease:
def __init__(self, **_kwargs): pass
def wait(self, update_state=None): return None
def release(self, **_kwargs): return None
def fake_run_stage(**kwargs):
captured.update(kwargs)
return "✅ done", _emplot_manifest(workspace_root / "job")
monkeypatch.setattr(resource_governor, "ResourceLease", NoopLease)
monkeypatch.setattr(resource_governor, "get_task", lambda _task_id: {})
monkeypatch.setattr(resource_governor, "snapshot", lambda **_kwargs: {"usage": {}})
monkeypatch.setattr(api, "WORKSPACE_ROOT", workspace_root)
monkeypatch.setattr(api, "INPUT_ROOT", tmp_path / "input")
monkeypatch.setattr(api, "_attach_single_stage_status", lambda _task: None)
monkeypatch.setattr(api, "_attach_annotation_progress", lambda _task: None)
monkeypatch.setattr(api.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(backend, "run_stage", fake_run_stage)
api.tasks["emplot-task"] = {
"id": "emplot-task", "stage": "gwas_emplot", "job_id": "job",
"status": "pending", "message": "", "created_at": "2026-07-21T00:00:00",
}
params = {
"prefix": "cohort", "gwasEmmaxPvalues": "p", "gwasEmmaxManifest": "m",
"gwasEmmaxTestedHypotheses": 87466, "gwasRscript": RSCRIPT, **_scopes(),
}
api.run_stage_sync("emplot-task", "gwas_emplot", "job", params)
assert {key: captured[key] for key in (
"gwas_emmax_pvalues", "gwas_emmax_manifest",
"gwas_emmax_tested_hypotheses", "source_model_scope_id",
)} == {
"gwas_emmax_pvalues": "p", "gwas_emmax_manifest": "m",
"gwas_emmax_tested_hypotheses": 87466,
"source_model_scope_id": SOURCE_MODEL,
}
task = api.tasks["emplot-task"]
assert task["status"] == "success", task
assert len(task["result"]["stage_files"]["gwas_emplot"]["images"]) == 2
assert {item["role"] for item in task["result"]["stage_files"]["gwas_emplot"]["files"]} == {
"report", "environment", "manifest",
}
assert task["result"]["stage_files"]["gwas_emplot"]["elapsed_time"] == 3.3
def test_api_task_rejects_partial_emplot_postflight(monkeypatch, tmp_path):
import gfa_core.mas.resource_governor as resource_governor
workspace_root = tmp_path / "workspace"
class NoopLease:
def __init__(self, **_kwargs): pass
def wait(self, update_state=None): return None
def release(self, **_kwargs): return None
monkeypatch.setattr(resource_governor, "ResourceLease", NoopLease)
monkeypatch.setattr(resource_governor, "get_task", lambda _task_id: {})
monkeypatch.setattr(resource_governor, "snapshot", lambda **_kwargs: {"usage": {}})
monkeypatch.setattr(api, "WORKSPACE_ROOT", workspace_root)
monkeypatch.setattr(api, "INPUT_ROOT", tmp_path / "input")
monkeypatch.setattr(api, "_attach_single_stage_status", lambda _task: None)
monkeypatch.setattr(api, "_attach_annotation_progress", lambda _task: None)
monkeypatch.setattr(api.time, "sleep", lambda _seconds: None)
monkeypatch.setattr(
backend, "run_stage", lambda **_kwargs: (
"✅ done", _emplot_manifest(
workspace_root / "job", roles=("emmax_manhattan", "report", "environment"),
),
),
)
api.tasks["partial-emplot"] = {
"id": "partial-emplot", "stage": "gwas_emplot", "job_id": "job",
"status": "pending", "message": "", "created_at": "2026-07-21T00:00:00",
}
api.run_stage_sync("partial-emplot", "gwas_emplot", "job", {"prefix": "cohort", **_scopes()})
task = api.tasks["partial-emplot"]
assert task["status"] == "failed"
assert task["result"]["error"] == "invalid_gwas_emplot_outputs"
assert "images" not in task["result"] and "files" not in task["result"]
def test_emplot_frontend_has_typed_pending_same_prefix_handoff_and_roles():
frontend = (Path(api.PROJECT_ROOT) / "apps/web/vue3/index.html").read_text(encoding="utf-8")
assert "handoffGwasEmmaxPlotProducerIdentity" in frontend
assert "stageExecutionStatus.value.gwas_emplot !== 'pending'" in frontend
assert "completedStage !== 'gwas_emmax'" in frontend
assert "sourceModelScopeId" in frontend
for role in ("emmax_manhattan", "emmax_qq", "manifest", "report", "environment"):
assert role in frontend
def test_emplot_frontend_handoff_materializes_only_completed_same_prefix_identity(tmp_path):
frontend = (Path(api.PROJECT_ROOT) / "apps/web/vue3/index.html").read_text(encoding="utf-8")
handoff_start = frontend.index("const handoffGwasEmmaxPlotProducerIdentity =")
apply_start = frontend.index("const applyGwasExecutionParams =", handoff_start)
stages = [{"id": "gwas_emmax", "params": {}}, {"id": "gwas_emplot", "params": {}}]
producer = {
"status": "success", "result": {
"stage_identity": {
"stage": "gwas_emmax", "prefix": "cohort",
"genotype_scope_id": GENOTYPE, "phenotype_scope_id": PHENOTYPE,
"model_scope_id": SOURCE_MODEL,
},
"stage_status": {"stage": "gwas_emmax", "prefix": "cohort", "completed": True},
},
}
script = tmp_path / "emplot-handoff.cjs"
script.write_text(
"const batchExecuteStages={value:" + json.dumps(stages) + "};"
"const stageExecutionStatus={value:{gwas_emmax:'running',gwas_emplot:'pending'}};"
"const getStageId=s=>s.id;const executionPrefix=()=> 'cohort';"
+ frontend[handoff_start:apply_start]
+ "const ok=handoffGwasEmmaxPlotProducerIdentity('gwas_emmax',"
+ json.dumps(producer) + ");"
+ "process.stdout.write(JSON.stringify({ok,params:batchExecuteStages.value[1].params}));",
encoding="utf-8",
)
completed = subprocess.run(["node", str(script)], text=True, capture_output=True, check=False)
assert completed.returncode == 0, completed.stderr
payload = json.loads(completed.stdout)
assert payload == {"ok": True, "params": _scopes()}
def _run_emplot_frontend_queries(
tmp_path: Path, stage_params: dict, stage_result: dict | None = None,
stage_filter: str = "gwas_emplot",
) -> dict:
frontend = (Path(api.PROJECT_ROOT) / "apps/web/vue3/index.html").read_text(encoding="utf-8")
helper_start = frontend.index("const gwasEmplotExactIdentity =")
check_start = frontend.index("const checkManifestExists =", helper_start)
check_end = frontend.index("const pollTaskUntilComplete =", check_start)
fetch_start = frontend.index("const fetchStageCompleted =", check_end)
fetch_end = frontend.index("const executeStageDirect =", fetch_start)
refresh_start = frontend.index("const refreshResults =", fetch_end)
refresh_end = frontend.index("// 打开图片预览", refresh_start)
script = tmp_path / "emplot-query.cjs"
script.write_text(
"const params={prefix:'cohort'};const jobInfo={job_id:'job'};"
"const console={log:()=>{},error:()=>{}};"
"const batchExecuteParams={value:{plan:{params:{}}}};"
"const batchExecuteStages={value:[{id:'gwas_emplot',params:" + json.dumps(stage_params) + "}]};"
"const activeGenotypeScopeId={value:''};const activeRnaseqScopeId={value:''};"
"const confirmParams={};const normalizedPrefix=x=>x;const executionPrefix=()=> 'cohort';"
"const memberSetForScope=()=>'';const getStageId=s=>s.id;"
"const resultStageAliases={};const stages=[{name:'gwas_emplot',images:[],files:[]}];"
"const activeGeneFamilyScopeId=()=>'';const refreshMcscanxHtml=async()=>{};"
"const urls=[];const fetch=async url=>{urls.push(url);return {json:async()=>"
"(url.startsWith('/api/stage-status')?{completed:true}:{gwas_emplot:{images:['a','b'],files:[],elapsed_time:3.3}})};};"
+ frontend[helper_start:check_start]
+ frontend[check_start:check_end]
+ frontend[fetch_start:fetch_end]
+ frontend[refresh_start:refresh_end]
+ ("rememberGwasEmplotIdentity('gwas_emplot'," + json.dumps(stage_result) + ");" if stage_result else "")
+ "Promise.all([checkManifestExists('gwas_emplot','cohort',batchExecuteStages.value[0].params),"
+ "fetchStageCompleted('gwas_emplot','cohort',batchExecuteStages.value[0].params)])"
+ ".then(async status=>{await refreshResults('job'," + json.dumps(stage_filter) + ");"
+ "process.stdout.write(JSON.stringify({status,urls,stage:stages[0]}));})"
+ ".catch(e=>{process.stderr.write(String(e));process.exit(1);});",
encoding="utf-8",
)
completed = subprocess.run(["node", str(script)], text=True, capture_output=True, check=False)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_emplot_frontend_status_and_results_send_all_four_scopes(tmp_path):
payload = _run_emplot_frontend_queries(tmp_path, _scopes(), {
"result": {"stage_identity": {
"stage": "gwas_emplot", "prefix": "cohort",
"genotype_scope_id": GENOTYPE, "phenotype_scope_id": PHENOTYPE,
"source_model_scope_id": SOURCE_MODEL, "analysis_scope_id": ANALYSIS,
}},
})
assert payload["status"] == [True, True]
assert len(payload["stage"]["images"]) == 2
for url in payload["urls"]:
query = url.split("?", 1)[1]
for key, value in {
"genotype_scope_id": GENOTYPE, "phenotype_scope_id": PHENOTYPE,
"source_model_scope_id": SOURCE_MODEL, "analysis_scope_id": ANALYSIS,
}.items():
assert f"{key}={value.replace(':', '%3A')}" in query
def test_emplot_frontend_incomplete_identity_is_fail_closed_without_partial_status(tmp_path):
payload = _run_emplot_frontend_queries(tmp_path, _scopes())
assert payload["status"] == [False, False]
assert all("/api/stage-status" not in url for url in payload["urls"])
results_url = next(url for url in payload["urls"] if url.startswith("/api/results"))
assert not any(key in results_url for key in (
"genotype_scope_id", "phenotype_scope_id", "source_model_scope_id", "analysis_scope_id",
))
def test_emplot_unfiltered_refresh_overlays_exact_persisted_result(tmp_path):
stage_result = {"result": {"stage_identity": {
"stage": "gwas_emplot", "prefix": "cohort",
"genotype_scope_id": GENOTYPE, "phenotype_scope_id": PHENOTYPE,
"source_model_scope_id": SOURCE_MODEL, "analysis_scope_id": ANALYSIS,
}}}
payload = _run_emplot_frontend_queries(
tmp_path, _scopes(), stage_result, stage_filter="",
)
exact_urls = [
url for url in payload["urls"]
if url.startswith("/api/results?") and "stage=gwas_emplot" in url
]
assert len(exact_urls) == 1
assert len(payload["stage"]["images"]) == 2
assert payload["stage"]["elapsed_time"] == 3.3
def test_emplot_skill_declares_exact_runner_and_roles():
config = (Path(api.PROJECT_ROOT) / "skills/gwas-emmax-plot/config.yaml").read_text(encoding="utf-8")
skill = (Path(api.PROJECT_ROOT) / "skills/gwas-emmax-plot/SKILL.md").read_text(encoding="utf-8")
assert "apps.cli.run_gwas_emmax_plot" in config and "apps.cli.run_gwas_emmax_plot" in skill
for role in ("emmax_pvalues", "emmax_manhattan", "emmax_qq", "manifest", "report", "environment"):
assert role in config and role in skill