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

370 lines
12 KiB
Python

import asyncio
import hashlib
import json
from pathlib import Path
import pytest
from gfa_core.mas.agents.planning_v2 import PlanningAgentV2, StageStatus
from gfa_core.mas.artifact_resolver import ArtifactResolver
from gfa_core.mas.context_index import build_project_context
from gfa_core.mas.stage_capabilities import get_stage_capability
import gfa_core.wgs_upstream_manifest as wgs_manifest
WGS_SAMPLE_SET_ID = "wgs-samples:" + "a" * 64
WGS_REFERENCE_ID = "wgs-reference:" + "b" * 64
WGS_QC_ANALYSIS_ID = "wgs-analysis:" + "c" * 64
WGS_INDEX_ANALYSIS_ID = "wgs-analysis:" + "d" * 64
WGS_RUN_ID = "1" * 32
def _wgs_artifact(
workspace: Path,
bundle: Path,
name: str,
role: str,
*,
sample_id: str = "",
component: str = "",
) -> dict:
path = bundle / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(f"{role}:{sample_id or component or 'reference'}".encode())
artifact = {
"artifact": role,
"role": role,
"path": str(path.relative_to(workspace)),
"full_path": str(path.resolve()),
"size": path.stat().st_size,
"size_bytes": path.stat().st_size,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
if sample_id:
artifact["sample_id"] = sample_id
if component:
artifact["component"] = component
return artifact
def _write_wgs_align_authorities(
workspace: Path, prefix: str = "Cohort"
) -> dict[str, Path]:
final_dir = workspace / "final"
qc_bundle = final_dir / ".wgs_qc_runs" / prefix / WGS_RUN_ID
index_bundle = final_dir / ".wgs_index_runs" / prefix / WGS_RUN_ID
qc_artifacts = {}
for sample_id in ("S1", "S2"):
qc_artifacts[f"clean_reads_r1.{sample_id}"] = _wgs_artifact(
workspace,
qc_bundle,
f"{prefix}.wgs_qc.{sample_id}.R1.fastq.gz",
"clean_reads_r1",
sample_id=sample_id,
)
qc_artifacts[f"clean_reads_r2.{sample_id}"] = _wgs_artifact(
workspace,
qc_bundle,
f"{prefix}.wgs_qc.{sample_id}.R2.fastq.gz",
"clean_reads_r2",
sample_id=sample_id,
)
qc_manifest = {
"manifest_version": "1.0",
"stage": "wgs_qc",
"status": "success",
"prefix": prefix,
"job_id": "job-1",
"run_id": WGS_RUN_ID,
"workspace": str(workspace.resolve()),
"wgs_sample_set_id": WGS_SAMPLE_SET_ID,
"wgs_analysis_id": WGS_QC_ANALYSIS_ID,
"artifacts": qc_artifacts,
}
qc_path = final_dir / f"{prefix}.wgs_qc.manifest.json"
qc_path.parent.mkdir(parents=True, exist_ok=True)
qc_path.write_text(json.dumps(qc_manifest), encoding="utf-8")
index_artifacts = {
"reference_fasta": _wgs_artifact(
workspace, index_bundle, "reference.fasta", "reference_fasta"
),
"reference_fai": _wgs_artifact(
workspace, index_bundle, "reference.fasta.fai", "reference_fai"
),
"reference_dict": _wgs_artifact(
workspace, index_bundle, "reference.dict", "reference_dict"
),
}
for component in (".0123", ".amb", ".ann", ".bwt.2bit.64", ".pac"):
index_artifacts[f"bwa_mem2_index_component.{component}"] = _wgs_artifact(
workspace,
index_bundle,
f"reference.fasta{component}",
"bwa_mem2_index_component",
component=component,
)
index_manifest = {
"manifest_version": "1.0",
"stage": "wgs_index",
"status": "success",
"prefix": prefix,
"job_id": "job-1",
"run_id": WGS_RUN_ID,
"workspace": str(workspace.resolve()),
"wgs_reference_id": WGS_REFERENCE_ID,
"wgs_analysis_id": WGS_INDEX_ANALYSIS_ID,
"artifacts": index_artifacts,
}
index_path = final_dir / f"{prefix}.wgs_index.manifest.json"
index_path.write_text(json.dumps(index_manifest), encoding="utf-8")
return {
"qc_manifest": qc_path,
"index_manifest": index_path,
"missing_component": Path(
index_artifacts["bwa_mem2_index_component..ann"]["full_path"]
),
}
def test_wgs_qc_resolves_only_canonical_sample_yaml(tmp_path: Path) -> None:
input_dir = tmp_path / "input" / "job-1"
input_dir.mkdir(parents=True)
canonical = input_dir / "wgs_samples.yaml"
canonical.write_text("samples: {}\n", encoding="utf-8")
(input_dir / "samples.yaml").write_text("samples: {}\n", encoding="utf-8")
context = build_project_context(
job_id="job-1",
prefix="At",
project_root=tmp_path,
)
capability = get_stage_capability("wgs_qc")
assert capability is not None
decision = ArtifactResolver(context).resolve_need(
"wgs_qc", capability.consumes[0]
)
assert decision.resolved
assert decision.selected is not None
assert Path(decision.selected.path) == canonical
def test_wgs_index_preflight_materializes_resolved_reference(
tmp_path: Path, monkeypatch
) -> None:
from apps.web.vue3 import main as web_main
input_dir = tmp_path / "input" / "job-1"
input_dir.mkdir(parents=True)
reference = input_dir / "reference.9.1m.fasta"
reference.write_text(">chr1\nACGT\n", encoding="utf-8")
monkeypatch.setattr(web_main, "PROJECT_ROOT", tmp_path)
result = web_main._api_semantic_preflight(
"job-1",
"wgs_index",
"Cohort",
{"wgsReferenceFasta": ""},
)
assert result["valid"]
assert result["materialized_params"]["wgsReferenceFasta"] == str(reference)
def test_wgs_index_preflight_does_not_override_explicit_reference(
tmp_path: Path, monkeypatch
) -> None:
from apps.web.vue3 import main as web_main
input_dir = tmp_path / "input" / "job-1"
input_dir.mkdir(parents=True)
(input_dir / "auto-reference.fasta").write_text(
">chr1\nACGT\n", encoding="utf-8"
)
explicit = tmp_path / "explicit-reference.fasta"
explicit.write_text(">chr1\nTGCA\n", encoding="utf-8")
monkeypatch.setattr(web_main, "PROJECT_ROOT", tmp_path)
result = web_main._api_semantic_preflight(
"job-1",
"wgs_index",
"Cohort",
{"wgsReferenceFasta": str(explicit)},
)
assert result["valid"]
assert "wgsReferenceFasta" not in result.get("materialized_params", {})
def test_wgs_align_planning_accepts_complete_authoritative_inputs(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace" / "job-1"
_write_wgs_align_authorities(workspace)
input_dir = tmp_path / "input" / "job-1"
input_dir.mkdir(parents=True)
context = build_project_context(
job_id="job-1", prefix="Cohort", project_root=tmp_path
)
issues = PlanningAgentV2(None, project_root=str(tmp_path))._check_input_files(
"wgs_align", input_dir, workspace, "Cohort", context_index=context
)
assert not [issue for issue in issues if issue["severity"] == "error"]
assert not [
issue
for issue in issues
if "缺少 wgs_align 所需输入" in issue["message"]
and any(
role in issue["message"]
for role in (
"clean_reads_r1",
"clean_reads_r2",
"reference_fasta",
"reference_fai",
"reference_dict",
"bwa_mem2_index_component",
)
)
]
def test_wgs_align_planning_reports_one_invalid_authority_without_fallback(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace" / "job-1"
authorities = _write_wgs_align_authorities(workspace)
authorities["missing_component"].unlink()
input_dir = tmp_path / "input" / "job-1"
input_dir.mkdir(parents=True)
context = build_project_context(
job_id="job-1", prefix="Cohort", project_root=tmp_path
)
issues = PlanningAgentV2(None, project_root=str(tmp_path))._check_input_files(
"wgs_align", input_dir, workspace, "Cohort", context_index=context
)
errors = [issue for issue in issues if issue["severity"] == "error"]
assert errors == [
{
"severity": "error",
"category": "input",
"code": "invalid_wgs_manifest_identity",
"message": (
"artifact bwa_mem2_index_component..ann file is missing or not regular"
),
"suggestion": "修复 wgs_qc 或 wgs_index 权威 Manifest 后重试",
"auto_fixable": False,
}
]
assert not [
issue for issue in issues if "缺少 wgs_align 所需输入" in issue["message"]
]
def test_wgs_align_planning_allows_planned_upstream_rebuild(
tmp_path: Path, monkeypatch
) -> None:
agent = PlanningAgentV2(None, project_root=str(tmp_path))
def fail_if_read(*_args, **_kwargs):
raise wgs_manifest.WgsUpstreamManifestError(
"wgs_qc authority is invalid: upstream Manifest is unreadable"
)
monkeypatch.setattr(
"gfa_core.mas.agents.planning_v2.materialize_wgs_identity_params",
fail_if_read,
)
issues = agent._check_input_files(
"wgs_align",
tmp_path / "input",
tmp_path / "workspace" / "job-1",
"Cohort",
planned_stages=["wgs_qc", "wgs_index", "wgs_align"],
)
assert [issue["severity"] for issue in issues] == ["warning"]
assert issues[0]["code"] == "wgs_upstream_will_be_rebuilt"
def test_unique_wgs_prefix_ignores_stale_request_hint(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" / "job-1"
_write_wgs_align_authorities(workspace, "Os")
assert wgs_manifest.resolve_unique_wgs_prefix(
stage="wgs_align",
job_id="job-1",
params={},
workspace=workspace,
) == "Os"
def test_unique_wgs_prefix_rejects_multiple_complete_prefixes(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" / "job-1"
_write_wgs_align_authorities(workspace, "At")
_write_wgs_align_authorities(workspace, "Os")
with pytest.raises(
wgs_manifest.WgsUpstreamManifestError,
match="multiple complete WGS authority prefixes",
):
wgs_manifest.resolve_unique_wgs_prefix(
stage="wgs_align",
job_id="job-1",
params={},
workspace=workspace,
)
def test_planning_uses_manifest_prefix_for_wgs_consumer(
tmp_path: Path, monkeypatch
) -> None:
workspace = tmp_path / "workspace" / "job-1"
_write_wgs_align_authorities(workspace, "Os")
input_dir = tmp_path / "input" / "job-1"
input_dir.mkdir(parents=True)
(input_dir / "wgs_samples.yaml").write_text("samples: []\n", encoding="utf-8")
(input_dir / "reference.fa").write_text(">chr1\nACGT\n", encoding="utf-8")
agent = PlanningAgentV2(None, project_root=str(tmp_path))
checked_prefixes: list[str] = []
monkeypatch.setattr(
agent,
"_check_stage_status_preflight",
lambda stage, prefix, checked_workspace, stage_params=None: (
checked_prefixes.append(prefix)
or StageStatus(stage=stage, status="not_found")
),
)
intent = {"stages": ["wgs_align"], "prefix": "At", "params": {}}
result = asyncio.run(
agent._run_preflight_validation(intent, "job-1", "At", str(workspace))
)
assert result["valid"]
assert intent["prefix"] == "Os"
assert checked_prefixes and set(checked_prefixes) == {"Os"}
def test_execute_prefix_uses_same_manifest_resolution(
tmp_path: Path, monkeypatch
) -> None:
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
_write_wgs_align_authorities(workspace_root / "job-1", "Os")
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
assert web_main._resolve_wgs_execute_prefix(
job_id="job-1",
stage="wgs_align",
prefix="At",
params={},
) == "Os"