merge: resolve .gitignore conflict, keep remote memory ignore pattern

This commit is contained in:
sj
2026-08-09 09:05:11 +08:00
37 changed files with 36678 additions and 402 deletions
+844
View File
@@ -0,0 +1,844 @@
from __future__ import annotations
import json
from pathlib import Path
import re
import pytest
import gfa_core.gene_family_report as report_module
from gfa_core.gene_family_report import (
NoSuccessfulGeneFamilyEvidence,
generate_gene_family_report,
)
def _write_manifest(
workspace: Path,
*,
prefix: str,
stage: str,
status: str = "success",
scope_id: str = "scope-a",
analysis_route: str = "post_subfamily_confirm",
member_set_id: str = "members-a",
metric_value: int = 1,
) -> Path:
final_dir = workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
token = f"{scope_id}.{analysis_route}.{member_set_id}"
table_path = final_dir / f"{prefix}.{token}.{stage}.tsv"
table_path.write_text(f"gene\tvalue\n{scope_id}_gene\t{metric_value}\n", encoding="utf-8")
manifest_path = final_dir / f"{prefix}.{token}.{stage}.manifest.json"
manifest = {
"manifest_version": "1.0",
"stage": stage,
"status": status,
"prefix": prefix,
"scope_id": scope_id,
"analysis_route": analysis_route,
"member_set_id": member_set_id,
"parameters": {
"scope_id": scope_id,
"analysis_route": analysis_route,
"member_set_id": member_set_id,
},
"metrics": {"member_count": metric_value},
"artifacts": {
"result_table": {
"path": f"final/{table_path.name}",
"full_path": str(table_path),
"type": "table",
}
},
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
return manifest_path
def _generate(
workspace: Path,
*,
expected_stages: list[str],
stage_statuses: dict[str, str],
use_model: bool = False,
action: str = "generate",
):
return generate_gene_family_report(
workspace,
"Ft.SPL",
expected_stages=expected_stages,
stage_statuses=stage_statuses,
scope_id="scope-a",
analysis_route="post_subfamily_confirm",
member_set_id="members-a",
use_model=use_model,
action=action,
job_id="job-report",
)
def _stage_section(markdown: str, stage: str) -> str:
heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", markdown)
if heading is None:
raise AssertionError(f"missing report chapter for {stage}")
next_heading = re.search(r"(?m)^## (?!#)", markdown[heading.end():])
end = heading.end() + next_heading.start() if next_heading else len(markdown)
return markdown[heading.start():end]
def _subsection(markdown: str, number: str) -> str:
heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", markdown)
if heading is None:
raise AssertionError(f"missing report subsection {number}")
next_heading = re.search(r"(?m)^#{2,3} ", markdown[heading.end():])
end = heading.end() + next_heading.start() if next_heading else len(markdown)
return markdown[heading.start():end]
def _valid_api_arrangement(payload: dict, *, reverse: bool = False) -> dict:
sections = []
for section in payload["sections"]:
sentence_ids = [sentence["sentence_id"] for sentence in section["sentences"]]
if reverse:
sentence_ids.reverse()
sections.append(
{
"section_id": section["section_id"],
"paragraphs": [
{
"sentence_ids": sentence_ids,
"connector_ids": ["also"] * max(0, len(sentence_ids) - 1),
}
],
}
)
return {"sections": sections}
def test_report_uses_only_planned_stages_and_current_identity(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
other = _write_manifest(
workspace,
prefix="Ft.SPL",
stage="identify",
scope_id="scope-b",
analysis_route="target_only",
member_set_id="members-b",
metric_value=99,
)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
)
report_text = result.report_path.read_text(encoding="utf-8")
assert "最终成员数:3" in report_text
assert other.name not in report_text
generated = {path.name for path in (workspace / "final").glob("Ft.SPL.gene_family_report.*")}
assert generated == {
"Ft.SPL.gene_family_report.md",
"Ft.SPL.gene_family_report.manifest.json",
}
def test_report_renders_template_subsections_for_only_planned_stages(tmp_path: Path) -> None:
workspace = tmp_path / "job"
for stage in ("identify", "motif", "pep"):
_write_manifest(workspace, prefix="Ft.SPL", stage=stage)
result = _generate(
workspace,
expected_stages=["identify", "motif", "pep"],
stage_statuses={"identify": "success", "motif": "success", "pep": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
expected_subsections = {
"identify": "### 4.3 最终成员数量与命名",
"motif": "### 7.2 Motif 数量与出现频率",
"pep": "### 16.1 蛋白理化性质分布图",
}
for stage, subsection in expected_subsections.items():
assert subsection in _stage_section(markdown, stage)
assert "(`synteny`)" not in markdown
assert "【填写】" not in markdown
def test_report_renders_nested_motif_metrics_without_placeholder_values(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="motif")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {
"motif_count": 10,
"total_sites": 105,
"motifs": [
{"width": width}
for width in (78, 76, 80, 113, 57, 29, 52, 138, 27, 29)
],
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["motif"],
stage_statuses={"motif": "success"},
)
motif_section = _stage_section(result.report_path.read_text(encoding="utf-8"), "motif")
assert re.search(r"识别\s*10\s*个\s*motif", motif_section)
assert re.search(r"总位点数为\s*105", motif_section)
assert re.search(r"宽度范围为\s*27–138", motif_section)
assert "未解析" not in motif_section
assert "N/A" not in motif_section
assert "None" not in motif_section
def test_identify_uses_real_manifest_member_count_aliases(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"identified_members_count": 24, "final_members": 24}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "identify")
assert "最终成员数:24" in section
assert re.search(r"形成\s*24\s*个\s*SPL家族", section)
assert "源结果未提供此指标" not in section
def test_phylogeny_renders_real_manifest_schema_with_explicit_labels(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["phylogeny"],
stage_statuses={"phylogeny": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "phylogeny")
assert "序列数:40" in section
assert "多序列比对长度:1449" in section
assert re.search(r"使用\s*40\s*条序列.*比对长度为\s*1449", section)
def test_interspecies_synteny_reads_only_named_real_schema_artifacts(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="interspecies_synteny")
final_dir = workspace / "final"
gene_to_species = final_dir / "Ft.SPL.gene_to_species.tsv"
gene_to_species.write_text(
"gene_id\tspecies\tgroup\n"
"FtGene1.01.T01\tFt\ttarget\n"
"FtGene2.01.T01\tFt\ttarget\n"
"AT1G00010\tAT\tref\n"
"AT1G00020\tAT\tref\n",
encoding="utf-8",
)
collinearity = final_dir / "Ft.At.collinearity"
lines = []
for index in range(521):
lines.append(f"## Alignment {index}: score=1 e_value=1e-10 N=1 Ft&At plus")
if index == 0:
lines.append(" 0- 0:\tAT1G00010.1\tFtGene1.01.T01\t1e-20")
elif index == 1:
lines.append(" 1- 0:\tAT1G00020.1\tFtGene2.01.T01\t1e-20")
collinearity.write_text("\n".join(lines) + "\n", encoding="utf-8")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"pair_success_count": 1, "pair_total_count": 1}
manifest["artifacts"] = {
"gene_to_species": {"path": f"final/{gene_to_species.name}", "type": "tsv"},
"Ft.At_collinearity": {"path": f"final/{collinearity.name}", "type": "data"},
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["interspecies_synteny"],
stage_statuses={"interspecies_synteny": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "interspecies_synteny")
assert "共线性区块数:521" in section
assert "家族成员目标—参考配对数:2" in section
assert re.search(r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对", section)
def test_pep_renders_real_manifest_task_summary_with_explicit_labels(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="pep")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"protein_count": 40, "tasks_success": 3, "tasks_total": 3}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["pep"],
stage_statuses={"pep": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "pep")
assert "蛋白数:40" in section
assert "子任务成功数:3/3" in section
assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", section)
def test_report_has_no_empty_third_level_subsections_or_repeated_generic_interpretation(
tmp_path: Path,
) -> None:
workspace = tmp_path / "job"
for stage in ("identify", "phylogeny", "motif"):
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage=stage)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if stage == "phylogeny":
manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449}
elif stage == "motif":
manifest["metrics"] = {
"sequence_count": 40,
"motif_count": 10,
"total_sites": 105,
"motifs": [{"width": 27}, {"width": 138}],
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify", "phylogeny", "motif"],
stage_statuses={stage: "success" for stage in ("identify", "phylogeny", "motif")},
)
markdown = result.report_path.read_text(encoding="utf-8")
for stage in ("identify", "phylogeny", "motif"):
section = _stage_section(markdown, stage)
chunks = re.split(r"(?m)(?=^### )", section)[1:]
assert chunks
for chunk in chunks:
body = "\n".join(chunk.splitlines()[1:]).strip()
assert body, f"empty subsection: {chunk.splitlines()[0]}"
assert markdown.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1
assert markdown.count("结果范围受当前输入、参数和源文件完整性限制。") <= 1
def test_front_summary_contains_project_numbers_in_six_to_ten_sentences(tmp_path: Path) -> None:
workspace = tmp_path / "job"
identify_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
identify = json.loads(identify_path.read_text(encoding="utf-8"))
identify["metrics"] = {"identified_members_count": 24, "final_members": 24}
identify_path.write_text(json.dumps(identify, ensure_ascii=False), encoding="utf-8")
phylogeny_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny")
phylogeny = json.loads(phylogeny_path.read_text(encoding="utf-8"))
phylogeny["metrics"] = {"sequences_count": 40, "msa_length": 1449}
phylogeny_path.write_text(json.dumps(phylogeny, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify", "phylogeny"],
stage_statuses={"identify": "success", "phylogeny": "success"},
)
summary = _subsection(result.report_path.read_text(encoding="utf-8"), "1.3")
assert re.search(r"目标物种.*24.*家族成员", summary)
assert re.search(r"目标.*参考.*40.*1449", summary)
assert 6 <= summary.count("。") <= 10
def test_stage_specific_key_facts_land_in_their_semantic_subsections(tmp_path: Path) -> None:
workspace = tmp_path / "job"
final_dir = workspace / "final"
stages = [
"reference_classification", "domain", "structure", "combo",
"promoter", "tfbs", "synteny",
]
manifests = {stage: _write_manifest(workspace, prefix="Ft.SPL", stage=stage) for stage in stages}
assignment = final_dir / "Ft.SPL.reference_classification.assignment.tsv"
assignment.write_text(
"target_gene\tassigned_subfamily\tconfidence\n"
+ "\n".join(
f"FtGene{i}\t{'I' if i < 2 else 'II'}\t{confidence}"
for i, confidence in enumerate(("high", "high", "medium", "low"), start=1)
)
+ "\n",
encoding="utf-8",
)
reference = json.loads(manifests["reference_classification"].read_text(encoding="utf-8"))
reference["metrics"] = {"target_gene_count": 4, "subfamily_rows": 6}
reference["artifacts"] = {"assignment_table": {"path": f"final/{assignment.name}", "type": "tsv"}}
manifests["reference_classification"].write_text(json.dumps(reference), encoding="utf-8")
domain = json.loads(manifests["domain"].read_text(encoding="utf-8"))
domain["metrics"] = {
"proteins_total": 40, "proteins_with_domains": 40, "domain_coverage_rate": 1.0,
"top_domains": [["SBP", 40], ["Ig_SPL7", 12]],
}
manifests["domain"].write_text(json.dumps(domain), encoding="utf-8")
structure = json.loads(manifests["structure"].read_text(encoding="utf-8"))
structure["metrics"] = {
"genes_total": 40, "genes_with_structure": 24, "structure_coverage_rate": 0.6,
"long_gene_outlier_count": 1,
"long_gene_outliers": [{"gene_id": "FtLong1", "length_bp": 12454, "cds_count": 10}],
}
manifests["structure"].write_text(json.dumps(structure), encoding="utf-8")
combo = json.loads(manifests["combo"].read_text(encoding="utf-8"))
combo["metrics"] = None
combo["parameters"] = {"panels": ["motif", "domain", "structure"]}
combo["artifacts"] = {
"combo_plot_png": {"path": "final/Ft.SPL.combo.png", "type": "image"},
"combo_plot_pdf": {"path": "final/Ft.SPL.combo.pdf", "type": "image"},
}
manifests["combo"].write_text(json.dumps(combo), encoding="utf-8")
element_counts = final_dir / "Ft.SPL.element_counts.txt"
element_counts.write_text("1460\tTATA-box\n852\tCAAT-box\n72\tMYB\n", encoding="utf-8")
category_summary = final_dir / "Ft.SPL.promoter_category_summary.tsv"
category_summary.write_text("big7\telement_instances\nCore promoter\t2600\nLight response\t188\n", encoding="utf-8")
enrichment = final_dir / "Ft.SPL.enrichment_analysis.txt"
enrichment.write_text(
"Subfamily\tElement\tCount\tSubfamilyTotal\tGlobalCount\tEnrichmentRatio\nI\tAACA_motif\t1\t222\t1\t16.66\n",
encoding="utf-8",
)
cooccurrence = final_dir / "Ft.SPL.cooccurrence_analysis.txt"
cooccurrence.write_text(
"Element1\tElement2\tCo-occurrenceCount\tGenes\nCAAT-box\tMYB\t23\tFtGene1,FtGene2\n",
encoding="utf-8",
)
complexity = final_dir / "Ft.SPL.complexity_analysis.txt"
complexity.write_text(
"Gene\tTotalElements\tUniqueElements\tComplexityScore\tComplexityClass\nFtComplex1\t278\t33\t361.44\tVery High\n",
encoding="utf-8",
)
promoter = json.loads(manifests["promoter"].read_text(encoding="utf-8"))
promoter["metrics"] = {"total_elements": 3699, "unique_elements": 87, "genes_with_elements": 23}
promoter["artifacts"] = {
"element_counts": {"path": f"final/{element_counts.name}"},
"viz_promoter_position_distribution_summary": {"path": f"final/{category_summary.name}"},
"enrichment_analysis": {"path": f"final/{enrichment.name}"},
"cooccurrence_analysis": {"path": f"final/{cooccurrence.name}"},
"complexity_analysis": {"path": f"final/{complexity.name}"},
}
manifests["promoter"].write_text(json.dumps(promoter), encoding="utf-8")
motif_summary = final_dir / "Ft.SPL.tfbs.motif_summary.tsv"
motif_summary.write_text(
"motif_id\tmotif_alt_id\tfamily_sites\tfamily_genes_with_site\tbackground_sites\tbackground_genes_with_site\n"
"MOTIF_A\t\t153\t18\t0\t0\n",
encoding="utf-8",
)
tfbs = json.loads(manifests["tfbs"].read_text(encoding="utf-8"))
tfbs["metrics"] = {
"family_promoter_count": 24, "background_promoter_count": 0,
"family_site_count": 3734, "family_motif_count": 230, "motif_database": "PlantTFDB",
}
tfbs["artifacts"] = {"motif_summary": {"path": f"final/{motif_summary.name}"}}
manifests["tfbs"].write_text(json.dumps(tfbs), encoding="utf-8")
synteny = json.loads(manifests["synteny"].read_text(encoding="utf-8"))
synteny["metrics"] = {"gene_count": 31679, "synteny_blocks": 316, "gene_pairs": 3267, "circos_chromosomes_plotted": 8}
manifests["synteny"].write_text(json.dumps(synteny), encoding="utf-8")
result = _generate(
workspace,
expected_stages=stages,
stage_statuses={stage: "success" for stage in stages},
)
markdown = result.report_path.read_text(encoding="utf-8")
assert re.search(r"4\s*个目标成员.*2\s*个小组.*高信心\s*2", _subsection(markdown, "6.2"))
assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(markdown, "8.1"))
assert re.search(r"24/40.*FtLong1.*12454", _subsection(markdown, "9.1"))
assert "Motif、Domain、Structure" in _subsection(markdown, "10.1")
promoter_text = _subsection(markdown, "11.1")
assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter_text)
assert "FtComplex1" in promoter_text and "CAAT-box—MYB" in promoter_text
assert re.search(r"24\s*条目标启动子.*3734.*MOTIF_A.*153", _subsection(markdown, "12.1"))
assert re.search(r"背景启动子为\s*0.*不能.*富集", _subsection(markdown, "12.1"))
assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", _subsection(markdown, "13.1"))
assert "源结果未提供家族专属复制基因对" in _subsection(markdown, "13.1")
def test_all_planned_success_is_final(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
_write_manifest(workspace, prefix="Ft.SPL", stage="motif")
result = _generate(
workspace,
expected_stages=["identify", "motif"],
stage_statuses={"identify": "success", "motif": "success"},
)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.report_status == "final"
assert manifest["metrics"]["report_status"] == "final"
assert manifest["metrics"]["included_stages"] == ["identify", "motif"]
def test_unsupported_successful_stage_is_visible_but_not_included_or_final(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
_write_manifest(workspace, prefix="Ft.SPL", stage="ppi")
result = _generate(
workspace,
expected_stages=["identify", "ppi"],
stage_statuses={"identify": "success", "ppi": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.report_status == "partial"
assert result.included_stages == ("identify",)
assert "ppi" in markdown
assert "不支持" in markdown
assert "(`ppi`)" not in markdown
assert manifest["metrics"]["included_stages"] == ["identify"]
assert manifest["metrics"]["unsupported_stages"] == ["ppi"]
@pytest.mark.parametrize("expected_stages", [None, []])
def test_generate_rejects_missing_plan_instead_of_inferring_historical_manifests(
tmp_path: Path, expected_stages: list[str] | None
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
with pytest.raises(ValueError, match="expected_stages"):
generate_gene_family_report(
workspace,
"Ft.SPL",
expected_stages=expected_stages,
stage_statuses={"identify": "success"},
scope_id="scope-a",
analysis_route="post_subfamily_confirm",
member_set_id="members-a",
use_model=False,
action="generate",
)
assert not list((workspace / "final").glob("*.gene_family_report.*"))
def test_report_selects_key_facts_without_dumping_manifest_or_stale_paths(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"].update({"total_proteins": 31679, "blast_enabled": True})
manifest["artifacts"]["result_table"]["path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv"
manifest["artifacts"]["result_table"]["full_path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
assert "最终成员数:3" in _subsection(markdown, "4.3")
assert not re.search(r"(?m)^- `(?:member_count|total_proteins|blast_enabled)\s*`:", markdown)
assert "/home/xzq/" not in markdown
def test_fallback_uses_nested_karyotype_metrics(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="karyotype")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {
"species": {
"Ft": {
"gene_count": 24,
"chromosome_count": 8,
"chromosomes": [f"Ft{i}" for i in range(1, 9)],
"tandem_count": 0,
}
}
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["karyotype"],
stage_statuses={"karyotype": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
assert "24个家族成员定位到8条染色体" in markdown
assert "核型分析将N/A" not in markdown
def test_artifact_path_remaps_remote_job_to_current_mount(tmp_path: Path) -> None:
from gfa_core.mas.agents.interpretation_evidence import _artifact_path
final_dir = tmp_path / "job" / "final"
final_dir.mkdir(parents=True)
manifest_path = final_dir / "Ft.SPL.stage.manifest.json"
local_artifact = final_dir / "Ft.SPL.result.tsv"
local_artifact.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8")
manifest = {
"_manifest_path": str(manifest_path),
"artifacts": {
"result": {
"full_path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv",
"path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv",
}
},
}
assert _artifact_path(manifest, "result") == local_artifact
def test_failed_or_missing_planned_stage_is_partial(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
_write_manifest(workspace, prefix="Ft.SPL", stage="motif", status="failed")
result = _generate(
workspace,
expected_stages=["identify", "motif"],
stage_statuses={"identify": "success", "motif": "failed"},
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.report_status == "partial"
assert "motif" in markdown.lower()
assert "failed" in markdown.lower()
assert manifest["metrics"]["failed_or_missing_stages"] == ["motif"]
def test_model_failure_creates_labeled_fallback_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
def fail_model(_evidence: dict, _stages: list[str]):
raise TimeoutError("provider timeout")
monkeypatch.setattr(report_module, "_generate_api_narrative", fail_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "生成模式:候补脚本" in markdown
assert manifest["metrics"]["generation_mode"] == "fallback_script"
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
assert "{'action':" not in markdown
def test_valid_model_sections_create_api_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
seen_payload: dict = {}
def valid_model(payload: dict, _stages: list[str]):
seen_payload.update(payload)
return (
_valid_api_arrangement(payload, reverse=True),
{"status": "generated", "provider": "test", "model": "test-model"},
)
monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "api"
assert set(seen_payload) == {"sections", "allowed_connectors"}
assert all(set(section) == {"section_id", "sentences"} for section in seen_payload["sections"])
assert all(
set(sentence) == {"sentence_id", "text"}
for section in seen_payload["sections"]
for sentence in section["sentences"]
)
assert "最终成员数:1" in markdown
assert manifest["metrics"]["generation_mode"] == "api"
assert "【填写】" not in markdown
def test_unknown_model_sentence_id_falls_back_for_the_whole_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
def invalid_model(payload: dict, _stages: list[str]):
response = _valid_api_arrangement(payload)
response["sections"][0]["paragraphs"][0]["sentence_ids"][0] = "unknown.sentence"
return response, {"status": "generated"}
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "最终成员数:3" in markdown
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
def test_missing_and_duplicate_model_sentence_ids_fall_back_for_the_whole_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
def invalid_model(payload: dict, _stages: list[str]):
response = _valid_api_arrangement(payload)
paragraph = next(
item["paragraphs"][0]
for item in response["sections"]
if len(item["paragraphs"][0]["sentence_ids"]) >= 2
)
paragraph["sentence_ids"][-1] = paragraph["sentence_ids"][0]
return response, {"status": "generated"}
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "最终成员数:3" in markdown
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
@pytest.mark.parametrize(
"violation",
["unknown_connector", "extra_field", "free_text_field", "changed_section"],
)
def test_model_schema_or_connector_boundary_violation_falls_back_for_the_whole_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, violation: str
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
def invalid_model(payload: dict, _stages: list[str]):
response = _valid_api_arrangement(payload)
if violation == "unknown_connector":
paragraph = next(
item["paragraphs"][0]
for item in response["sections"]
if item["paragraphs"][0]["connector_ids"]
)
paragraph["connector_ids"][0] = "invented"
elif violation == "extra_field":
response["unexpected"] = True
elif violation == "free_text_field":
response["sections"][0]["paragraphs"][0]["text"] = "API 自由文本"
else:
response["sections"][0]["section_id"] = "changed.section"
return response, {"status": "generated"}
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "最终成员数:3" in markdown
assert "API 自由文本" not in markdown
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
def test_open_reuses_existing_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
calls = {"count": 0}
def valid_model(payload: dict, _stages: list[str]):
calls["count"] += 1
return (
_valid_api_arrangement(payload),
{"status": "generated", "provider": "test", "model": "test-model"},
)
monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model)
first = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
reopened = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
action="open",
)
assert first.reused is False
assert reopened.reused is True
assert calls["count"] == 1
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"]["member_count"] = 2
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
reopened_after_source_change = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
action="open",
)
assert reopened_after_source_change.reused is True
assert calls["count"] == 1
def test_no_successful_evidence_creates_no_empty_report(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", status="failed")
with pytest.raises(NoSuccessfulGeneFamilyEvidence, match="暂无可生成"):
_generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "failed"},
)
assert not list((workspace / "final").glob("*.gene_family_report.*"))
@@ -0,0 +1,112 @@
from __future__ import annotations
import asyncio
import json
import pytest
from gfa_core.mas.core.fast_intent import fast_intent_recognition
from gfa_core.mas.utils.intent_parser import parse_intent_from_query
@pytest.mark.parametrize(
("query", "action"),
[
("生成结果报告", "generate"),
("生成基因家族结果报告", "generate"),
("打开基因家族结果报告", "open"),
],
)
def test_explicit_gene_family_report_intent_is_provider_independent(query: str, action: str) -> None:
for parsed in (parse_intent_from_query(query), fast_intent_recognition(query)):
assert parsed is not None
assert parsed["stages"] == ["gene_family_report"]
assert parsed["params"]["geneFamilyReportAction"] == action
assert parsed["params"]["reportAction"] == action
assert parsed["method"] == "gene_family_report_deterministic"
def test_rnaseq_report_intent_is_not_stolen() -> None:
for parsed in (
parse_intent_from_query("打开 RNA-seq 结果报告"),
fast_intent_recognition("打开 RNA-seq 结果报告"),
):
assert not parsed or parsed.get("stages") != ["gene_family_report"]
@pytest.mark.parametrize(
("query", "action"),
[
("生成结果报告", "generate"),
("生成基因家族结果报告", "generate"),
("打开基因家族结果报告", "open"),
],
)
def test_gene_family_report_chat_bypasses_router(
monkeypatch,
tmp_path,
query: str,
action: str,
) -> None:
from apps.web.vue3 import main as web_main
from gfa_core.mas.agents import router as router_module
final_dir = tmp_path / "workspace" / "job-report" / "final"
final_dir.mkdir(parents=True)
(final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text(
json.dumps(
{
"stage": "gene_family_report",
"status": "success",
"prefix": "Ft.SPL",
"parameters": {
"expected_stages": ["identify", "motif"],
"stage_statuses": {"identify": "success", "motif": "success"},
},
}
),
encoding="utf-8",
)
async def forbidden_router(*_args, **_kwargs):
raise AssertionError("report intent must return before Router/LLM")
class ForbiddenOrchestrator:
async def plan(self, *_args, **_kwargs):
raise AssertionError("report plan must not call the LLM orchestrator")
monkeypatch.setattr(web_main, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path / "workspace")
monkeypatch.setattr(web_main, "agent_conversation_store", None, raising=False)
monkeypatch.setattr(web_main, "llm_manager", object())
monkeypatch.setattr(web_main, "orchestrator", ForbiddenOrchestrator())
monkeypatch.setattr(web_main, "MAS_AVAILABLE", True)
monkeypatch.setattr(router_module.RouterAgent, "execute", forbidden_router)
result = asyncio.run(
web_main._run_agent_chat(
web_main.AgentChatRequest(message=query, job_id="job-report")
)
)
assert result["action"] == "propose_analysis"
assert result["model_used"] == "rule"
assert result["intent"]["stages"] == ["gene_family_report"]
assert result["intent"]["prefix"] == "Ft.SPL"
assert result["intent"]["params"]["reportAction"] == action
assert result["intent"]["params"]["expectedStages"] == ["identify", "motif"]
plan = asyncio.run(
web_main.agent_plan(
web_main.AgentPlanRequest(
message=query,
job_id="job-report",
prefix="Ft.SPL",
intent=result["intent"],
)
)
)
assert plan["success"] is True
assert plan["mode"] == "gene_family_report_deterministic"
assert plan["plan"]["stages"] == ["gene_family_report"]
assert plan["plan"]["params"]["expectedStages"] == ["identify", "motif"]
@@ -0,0 +1,181 @@
from __future__ import annotations
import json
from pathlib import Path
def _request_and_stages():
from apps.web.vue3.main import ConfirmedPlanExecutionRequest, ConfirmedPlanStage
stages = [
ConfirmedPlanStage(
id="identify",
params={
"scopeId": "scope-a",
"analysisRoute": "post_subfamily_confirm",
"memberSetId": "members-a",
},
),
ConfirmedPlanStage(id="motif", dependencies=["identify"]),
ConfirmedPlanStage(id="pep", dependencies=["motif"]),
]
request = ConfirmedPlanExecutionRequest(
session_id="session-a",
job_id="job-a",
prefix="Ft.SPL",
plan_id="plan-a",
stages=stages,
)
return request, stages
def test_confirmed_plan_terminal_success_requests_final_report() -> None:
from apps.web.vue3.main import _confirmed_plan_report_request
request, stages = _request_and_stages()
report = _confirmed_plan_report_request(
request,
stages,
{"status": "success", "completed_stages": ["identify", "motif", "pep"]},
)
assert report is not None
assert report.stage == "gene_family_report"
assert report.expectedStages == ["identify", "motif", "pep"]
assert report.stageStatuses == {"identify": "success", "motif": "success", "pep": "success"}
assert report.reportAction == "generate"
def test_confirmed_plan_failure_requests_partial_report() -> None:
from apps.web.vue3.main import _confirmed_plan_report_request
request, stages = _request_and_stages()
report = _confirmed_plan_report_request(
request,
stages,
{
"status": "failed",
"completed_stages": ["identify"],
"failure": {"stage": "motif"},
},
)
assert report is not None
assert report.stageStatuses == {"identify": "success", "motif": "failed", "pep": "not_run"}
assert report.scopeId == "scope-a"
assert report.analysisRoute == "post_subfamily_confirm"
assert report.memberSetId == "members-a"
def test_paused_plan_does_not_auto_finalize_report() -> None:
from apps.web.vue3.main import _confirmed_plan_report_request
request, stages = _request_and_stages()
report = _confirmed_plan_report_request(
request,
stages,
{"status": "paused", "completed_stages": ["identify"]},
)
assert report is None
def test_frontend_batch_terminal_path_calls_report_stage_with_planned_statuses() -> None:
source = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8")
assert "submitGeneFamilyReportForBatch" in source
assert "executeSingleStageWithParams('gene_family_report'" in source
assert "expectedStages: plannedIds" in source
assert "stageStatuses" in source
assert "if (pausedCount > 0) return" in source
assert "continuationOpened" in source
assert "name: 'gene_family_report'" in source
assert "openGeneratedGeneFamilyReport" in source
def test_report_context_prefers_terminal_confirmed_plan(monkeypatch, tmp_path: Path) -> None:
from apps.web.vue3 import main as web_main
final_dir = tmp_path / "job-a" / "final"
confirmed_dir = final_dir / ".confirmed_plans"
confirmed_dir.mkdir(parents=True)
(final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text(
json.dumps(
{
"stage": "gene_family_report",
"status": "success",
"prefix": "Ft.SPL",
"parameters": {
"expected_stages": ["identify"],
"stage_statuses": {"identify": "success"},
},
}
),
encoding="utf-8",
)
(confirmed_dir / "0123456789abcdefabcd.json").write_text(
json.dumps(
{
"status": "failed",
"job_id": "job-a",
"prefix": "Ft.SPL",
"stages": ["identify", "motif", "pep", "gene_family_report"],
"completed_stages": ["identify", "motif"],
"failure": {"stage": "pep"},
"scopeId": "scope-a",
"memberSetId": "members-a",
}
),
encoding="utf-8",
)
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
context = web_main._gene_family_report_plan_context("job-a")
assert context == {
"prefix": "Ft.SPL",
"expectedStages": ["identify", "motif", "pep"],
"stageStatuses": {"identify": "success", "motif": "success", "pep": "failed"},
"scopeId": "scope-a",
"analysisRoute": "",
"memberSetId": "members-a",
"rnaseqScopeId": "",
}
def test_report_context_reuses_formal_report_plan_only(monkeypatch, tmp_path: Path) -> None:
from apps.web.vue3 import main as web_main
final_dir = tmp_path / "job-a" / "final"
final_dir.mkdir(parents=True)
(final_dir / "Ft.SPL.motif.manifest.json").write_text(
json.dumps({"stage": "motif", "status": "success", "prefix": "Ft.SPL"}),
encoding="utf-8",
)
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", tmp_path)
assert web_main._gene_family_report_plan_context("job-a") is None
(final_dir / "Ft.SPL.gene_family_report.manifest.json").write_text(
json.dumps(
{
"stage": "gene_family_report",
"status": "success",
"prefix": "Ft.SPL",
"scope_id": "scope-old",
"parameters": {
"expected_stages": ["identify", "motif"],
"stage_statuses": {"identify": "success", "motif": "success"},
},
}
),
encoding="utf-8",
)
context = web_main._gene_family_report_plan_context("job-a", "Ft.SPL")
assert context is not None
assert context["expectedStages"] == ["identify", "motif"]
assert context["stageStatuses"] == {"identify": "success", "motif": "success"}
assert context["scopeId"] == ""
assert context["analysisRoute"] == ""
assert context["memberSetId"] == ""
assert context["rnaseqScopeId"] == ""
@@ -0,0 +1,347 @@
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
def _write_identify_manifest(workspace: Path, prefix: str = "Ft.SPL") -> Path:
final_dir = workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
table = final_dir / f"{prefix}.identify.tsv"
table.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8")
manifest = final_dir / f"{prefix}.identify.manifest.json"
manifest.write_text(
json.dumps(
{
"manifest_version": "1.0",
"stage": "identify",
"status": "success",
"prefix": prefix,
"metrics": {"member_count": 1},
"artifacts": {
"members": {
"path": f"final/{table.name}",
"full_path": str(table),
"type": "table",
}
},
},
ensure_ascii=False,
),
encoding="utf-8",
)
return manifest
def _flag_value(command: list[str], flag: str) -> str:
return command[command.index(flag) + 1]
def test_report_stage_is_registered_without_analysis_dependencies() -> None:
from gfa_core.mas.stage_capabilities import CAPABILITIES
from gfa_core.mas.stage_catalog import STAGE_ORDER, is_stage_executable
from gfa_core.mas.tools.stage_tools import create_stage_registry
capability = CAPABILITIES["gene_family_report"]
stage_tool = create_stage_registry().get("gene_family_report")
assert "gene_family_report" in STAGE_ORDER
assert is_stage_executable("gene_family_report")
assert capability.consumes == []
assert stage_tool is not None
assert stage_tool.required_inputs == []
assert stage_tool.script == "run_gene_family_report.py"
def test_report_cli_can_start_as_a_script() -> None:
root = Path(__file__).resolve().parents[2]
completed = subprocess.run(
[sys.executable, "apps/cli/run_gene_family_report.py", "--help"],
cwd=root,
capture_output=True,
text=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
def test_report_cli_writes_report_and_manifest(tmp_path: Path) -> None:
from apps.cli.run_gene_family_report import main
workspace = tmp_path / "job"
_write_identify_manifest(workspace)
result = main(
[
"--workspace",
str(workspace),
"--prefix",
"Ft.SPL",
"--job-id",
"job-wiring",
"--expected-stages",
"identify",
"--stage-statuses-json",
'{"identify":"success"}',
"--no-model",
]
)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.report_path.is_file()
assert result.manifest_path.is_file()
assert "gene_family_report" in manifest["artifacts"]
def test_pipeline_backend_passes_plan_status_and_identity_to_report_cli(
tmp_path: Path, monkeypatch
) -> None:
from apps.web import pipeline_backend
workspace = tmp_path / "job"
input_dir = workspace / "input"
logs_dir = workspace / "logs"
input_dir.mkdir(parents=True)
logs_dir.mkdir(parents=True)
_write_identify_manifest(workspace)
captured: dict[str, list[str]] = {}
def capture(command, _log_file, cwd=None):
captured["command"] = [str(value) for value in command]
return 0
monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, ""))
monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture)
job = pipeline_backend.JobInfo(
job_id="job-backend",
input_dir=str(input_dir),
workspace_dir=str(workspace),
logs_dir=str(logs_dir),
created_at="2026-08-08 00:00:00",
)
pipeline_backend.run_stage(
"gene_family_report",
job,
"Ft.SPL",
expected_stages=["identify", "motif"],
stage_statuses={"identify": "success", "motif": "failed"},
report_action="open",
report_use_model=False,
scope_id="scope-a",
analysis_route="post_subfamily_confirm",
member_set_id="members-a",
rnaseq_scope_id="rna-a",
)
command = captured["command"]
assert command[1:3] == ["apps/cli/run_gene_family_report.py", "--workspace"]
assert _flag_value(command, "--expected-stages") == "identify,motif"
assert json.loads(_flag_value(command, "--stage-statuses-json")) == {
"identify": "success",
"motif": "failed",
}
assert _flag_value(command, "--action") == "open"
assert _flag_value(command, "--job-id") == "job-backend"
assert _flag_value(command, "--scope-id") == "scope-a"
assert _flag_value(command, "--analysis-route") == "post_subfamily_confirm"
assert _flag_value(command, "--member-set-id") == "members-a"
assert _flag_value(command, "--rnaseq-scope-id") == "rna-a"
assert "--no-model" in command
assert not any("run_identify.py" in value or "run_motif.py" in value for value in command)
def test_pipeline_backend_rejects_empty_plan_before_starting_report_cli(
tmp_path: Path, monkeypatch
) -> None:
from apps.web import pipeline_backend
workspace = tmp_path / "job"
input_dir = workspace / "input"
logs_dir = workspace / "logs"
input_dir.mkdir(parents=True)
logs_dir.mkdir(parents=True)
invoked = False
def capture(*_args, **_kwargs):
nonlocal invoked
invoked = True
return 0
monkeypatch.setattr(pipeline_backend, "can_start_new_task", lambda: (True, ""))
monkeypatch.setattr(pipeline_backend, "run_cmd_logged", capture)
job = pipeline_backend.JobInfo(
job_id="job-empty-plan",
input_dir=str(input_dir),
workspace_dir=str(workspace),
logs_dir=str(logs_dir),
created_at="2026-08-08 00:00:00",
)
message, _manifest = pipeline_backend.run_stage(
"gene_family_report",
job,
"Ft.SPL",
expected_stages=[],
report_action="generate",
report_use_model=False,
)
assert "expected_stages" in message
assert invoked is False
def test_stage_tool_executor_forwards_gene_family_report_context(
tmp_path: Path, monkeypatch
) -> None:
import asyncio
import types
from dataclasses import dataclass
from gfa_core.mas.tools.stage_tools import StageTool, StageToolExecutor
captured = {}
@dataclass
class FakeJobInfo:
job_id: str
input_dir: str
workspace_dir: str
logs_dir: str
created_at: str
def fake_run_stage(**kwargs):
captured.update(kwargs)
manifest = Path(kwargs["job"].workspace_dir) / "final" / "Ft.SPL.gene_family_report.manifest.json"
manifest.parent.mkdir(parents=True, exist_ok=True)
manifest.write_text(
json.dumps({"stage": "gene_family_report", "status": "success"}),
encoding="utf-8",
)
return "报告已生成", manifest
fake_backend = types.ModuleType("apps.web.pipeline_backend")
fake_backend.JobInfo = FakeJobInfo
fake_backend.run_stage = fake_run_stage
monkeypatch.setitem(sys.modules, "apps.web.pipeline_backend", fake_backend)
result = asyncio.run(
StageToolExecutor(project_root=tmp_path).execute(
StageTool(
"gene_family_report",
"Gene Family Report",
"run_gene_family_report.py",
"report",
"test",
timeout=5,
),
"job-report",
"Ft.SPL",
{
"expectedStages": ["identify", "motif"],
"stageStatuses": {"identify": "success", "motif": "failed"},
"reportAction": "open",
"reportUseModel": False,
"scopeId": "scope-a",
"rnaseqScopeId": "rna-a",
},
)
)
assert result["status"] == "success"
assert captured["expected_stages"] == ["identify", "motif"]
assert captured["stage_statuses"] == {"identify": "success", "motif": "failed"}
assert captured["report_action"] == "open"
assert captured["report_use_model"] is False
assert captured["scope_id"] == "scope-a"
assert captured["rnaseq_scope_id"] == "rna-a"
def test_execute_report_does_not_invent_scope_identity(monkeypatch) -> None:
import asyncio
from unittest.mock import Mock
from apps.web.vue3 import main as web_main
from gfa_core.mas import resource_governor
from tasks import execute_single_stage_task
submitted = Mock()
submitted.return_value.id = "report-task"
monkeypatch.setattr(execute_single_stage_task, "apply_async", submitted)
monkeypatch.setattr(
web_main,
"_ensure_celery_queue_health",
lambda *_args, **_kwargs: ({"available": True}, None),
)
monkeypatch.setattr(
web_main,
"_ensure_gene_family_scope_file",
lambda **_kwargs: ("", ""),
)
monkeypatch.setattr(resource_governor, "record_task", lambda **_kwargs: {})
request = web_main.ExecuteRequest(
stage="gene_family_report",
job_id="job-report-no-invented-identity",
prefix="Ft.SPL",
expectedStages=["identify", "motif"],
stageStatuses={"identify": "success", "motif": "success"},
)
result = asyncio.run(web_main.execute_stage(request))
assert result["success"] is True
params = submitted.call_args.kwargs["kwargs"]["params"]
assert params["scopeId"] == ""
assert params["analysisRoute"] == ""
assert params["memberSetId"] == ""
assert params["rnaseqScopeId"] == ""
def test_report_artifact_validation_does_not_require_invented_identity(monkeypatch) -> None:
from types import SimpleNamespace
import tasks
from gfa_core.mas.tools import stage_identity
captured = {}
identity = SimpleNamespace(
scope_id="",
analysis_route="custom",
member_set_id="Ft.SPL:custom:all_members",
wgs_sample_set_id="",
wgs_reference_id="",
wgs_analysis_id="",
genotype_scope_id="",
phenotype_scope_id="",
covariate_scope_id="",
model_scope_id="",
source_model_scope_id="",
analysis_scope_id="",
)
monkeypatch.setattr(stage_identity, "resolve_stage_identity", lambda **_kwargs: identity)
def capture_status(**kwargs):
captured.update(kwargs)
return SimpleNamespace(completed=True)
monkeypatch.setattr(stage_identity, "get_stage_status_for_identity", capture_status)
failure = tasks.validate_single_stage_artifacts(
"job-report",
"Ft.SPL",
"gene_family_report",
{
"manifest": {
"stage": "gene_family_report",
"status": "success",
"prefix": "Ft.SPL",
"analysis_route": "",
"member_set_id": "",
}
},
)
assert failure is None
assert captured["strict_identity"] is False
File diff suppressed because it is too large Load Diff