1945 lines
70 KiB
Python
1945 lines
70 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from openpyxl import Workbook
|
|
|
|
from apps.web import pipeline_backend as backend
|
|
from apps.web.vue3 import main as web_main
|
|
from gfa_core.mas.tools.stage_tools import StageToolExecutor, StageToolRegistry
|
|
|
|
|
|
def _source_block(source: str, start: str, end: str) -> str:
|
|
start_index = source.index(start)
|
|
return source[start_index:source.index(end, start_index)]
|
|
|
|
|
|
def _run_node(source: str) -> dict:
|
|
completed = subprocess.run(
|
|
["/usr/bin/node", "-e", source],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def _ppi_scope_js() -> str:
|
|
return """
|
|
const uniqueList = value => [...new Set((Array.isArray(value) ? value : []).filter(Boolean))];
|
|
const scopeIdForRoles = (family, targets, refs, comps, mode) => `${family}.target-${targets.join('-')}.${mode}`;
|
|
const memberSetForScope = (scopeId, route) => `${scopeId}:${route}:all_members`;
|
|
"""
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stage", "mapping_owner", "confirmed", "expected"),
|
|
[
|
|
("ppi", "", True, "string_taxon_3562"),
|
|
("kaks", "ppi", True, "string_taxon_3562"),
|
|
("kaks", "kaks", True, "At"),
|
|
("ppi", "", False, "soe"),
|
|
],
|
|
)
|
|
def test_technical_reference_bundle_lookup_uses_storage_identity_only_for_ppi_owned_reference(
|
|
stage: str,
|
|
mapping_owner: str,
|
|
confirmed: bool,
|
|
expected: str,
|
|
) -> None:
|
|
request = web_main.ExecuteRequest(
|
|
stage=stage,
|
|
ppiReferenceSpecies="soe",
|
|
ppiReferenceStorageKey="string_taxon_3562",
|
|
ppiReferenceUserConfirmed=confirmed,
|
|
orthologMappingOwner=mapping_owner,
|
|
)
|
|
|
|
assert web_main._technical_reference_bundle_lookup_code(request, "soe" if stage == "ppi" else "At") == expected
|
|
|
|
|
|
def test_stage_tool_executor_forwards_kaks_ortholog_mapping_owner(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
) -> None:
|
|
captured: dict = {}
|
|
|
|
def fake_run_stage(**kwargs):
|
|
captured.update(kwargs)
|
|
manifest = Path(kwargs["job"].workspace_dir) / "final" / "Ft.SPL.kaks.manifest.json"
|
|
manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
manifest.write_text(
|
|
json.dumps({"manifest_version": "1.0", "stage": "kaks", "status": "success", "prefix": "Ft.SPL"}),
|
|
encoding="utf-8",
|
|
)
|
|
return "KaKs complete", manifest
|
|
|
|
monkeypatch.setattr(backend, "run_stage", fake_run_stage)
|
|
tool = StageToolRegistry().get("kaks")
|
|
result = asyncio.run(
|
|
StageToolExecutor(project_root=tmp_path).execute(
|
|
tool,
|
|
"job_owner_forwarding",
|
|
"Ft.SPL",
|
|
extra_params={"orthologMappingOwner": "ppi"},
|
|
)
|
|
)
|
|
|
|
assert result["status"] == "success"
|
|
assert captured["ortholog_mapping_owner"] == "ppi"
|
|
|
|
|
|
def test_confirmed_numeric_reference_uses_storage_key_for_local_id_map(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
) -> None:
|
|
input_dir = tmp_path / "input"
|
|
workspace = tmp_path / "workspace"
|
|
final_dir = workspace / "final"
|
|
logs_dir = workspace / "logs"
|
|
input_dir.mkdir()
|
|
final_dir.mkdir(parents=True)
|
|
logs_dir.mkdir(parents=True)
|
|
workbook = Workbook()
|
|
sheet = workbook.active
|
|
sheet.title = "config"
|
|
sheet.append(["Species_Code", "Species_Name", "TaxID"])
|
|
sheet.append(["Sv", "Setaria viridis", "4556"])
|
|
workbook.save(input_dir / "config.xlsx")
|
|
|
|
storage_dir = input_dir / "string_taxon_4555"
|
|
storage_dir.mkdir()
|
|
expected_id_map = storage_dir / "string_taxon_4555.pep_cds_idmap.tsv"
|
|
expected_id_map.write_text(
|
|
"species_code\tprotein_id\nstring_taxon_4555\tSeita.1\n",
|
|
encoding="utf-8",
|
|
)
|
|
(storage_dir / "string_taxon_4555.pep.fasta").write_text(
|
|
">Seita.1\nMA\n",
|
|
encoding="utf-8",
|
|
)
|
|
(final_dir / "Sv.HKL.final_members.id").write_text("SvGene1\n", encoding="utf-8")
|
|
(final_dir / "Sv.HKL.orthologs.txt").write_text(
|
|
"SvGene1\tSeita.1\t91\tSv\tstring_taxon_4555\n",
|
|
encoding="utf-8",
|
|
)
|
|
scope_file = final_dir / "Sv.HKL.target-ref.scope.json"
|
|
scope_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"family": "HKL",
|
|
"prefix": "Sv.HKL",
|
|
"scope_id": "HKL.target-Sv.ref-string_taxon_4555.target_plus_reference",
|
|
"scope_mode": "target_plus_reference",
|
|
"target_species": ["Sv"],
|
|
"reference_species": ["string_taxon_4555"],
|
|
"selected_species": ["Sv", "string_taxon_4555"],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
job = backend.JobInfo(
|
|
job_id="job_task5_storage",
|
|
input_dir=str(input_dir),
|
|
workspace_dir=str(workspace),
|
|
logs_dir=str(logs_dir),
|
|
created_at="",
|
|
)
|
|
launched: list[list[str]] = []
|
|
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"run_cmd_logged",
|
|
lambda cmd, *_args, **_kwargs: launched.append(list(cmd)) or 1,
|
|
)
|
|
|
|
message, _manifest = backend.run_stage(
|
|
"ppi",
|
|
job,
|
|
"Sv.HKL",
|
|
scope_file=str(scope_file),
|
|
ppi_species="4555",
|
|
normalization_species_label="sit",
|
|
)
|
|
|
|
wrong_id_map = input_dir / "4555" / "4555.pep_cds_idmap.tsv"
|
|
assert str(wrong_id_map) not in message
|
|
assert launched
|
|
command = launched[0]
|
|
assert command[command.index("--species") + 1] == "4555"
|
|
assert command[command.index("--id-map") + 1] == str(expected_id_map)
|
|
|
|
|
|
def test_ppi_owner_fields_survive_execute_and_worker_transport() -> None:
|
|
from apps.web.vue3.main import ExecuteRequest
|
|
from tasks import normalize_stage_params
|
|
|
|
request = ExecuteRequest(
|
|
stage="ppi",
|
|
job_id="job_task5_transport",
|
|
prefix="Sv.HKL",
|
|
ppiReferenceTaxonId="4555",
|
|
ppiReferenceStorageKey="string_taxon_4555",
|
|
ppiReferenceSelectionMode="tree_nearest",
|
|
ppiReferenceUserConfirmed=True,
|
|
dependencyOwner="ppi",
|
|
)
|
|
params, _profile = normalize_stage_params("ppi", request.dict())
|
|
|
|
assert params["ppiReferenceTaxonId"] == "4555"
|
|
assert params["ppiReferenceStorageKey"] == "string_taxon_4555"
|
|
assert params["ppiReferenceSelectionMode"] == "tree_nearest"
|
|
assert params["ppiReferenceUserConfirmed"] is True
|
|
assert params["dependencyOwner"] == "ppi"
|
|
|
|
|
|
def test_execute_scope_uses_confirmed_ppi_owner_storage_without_biological_scope_pollution() -> None:
|
|
request = web_main.ExecuteRequest(
|
|
stage="ortholog",
|
|
job_id="job_owner_scope",
|
|
prefix="Sv.HKL",
|
|
scopeMode="target_only",
|
|
analysisRoute="multi_species",
|
|
memberSetId="HKL.target-Sv.target_only:multi_species:all_members",
|
|
targetSpecies=["Sv"],
|
|
ppiReferenceTaxonId="4555",
|
|
ppiReferenceStorageKey="string_taxon_4555",
|
|
ppiReferenceUserConfirmed=True,
|
|
dependencyOwner="ppi",
|
|
)
|
|
scope_params = {
|
|
"scopeMode": "target_only", "targetSpecies": ["Sv"],
|
|
"referenceSpecies": [], "refSpeciesList": [],
|
|
}
|
|
|
|
technical_refs, ppi_owned_ortholog, needs_fallback = (
|
|
web_main._prepare_execute_technical_reference_scope(request, scope_params)
|
|
)
|
|
|
|
assert technical_refs == ["string_taxon_4555"]
|
|
assert ppi_owned_ortholog is True
|
|
assert needs_fallback is False
|
|
assert request.analysisRoute == "single_species"
|
|
assert request.memberSetId == ""
|
|
assert scope_params == {
|
|
"scopeMode": "target_only", "targetSpecies": ["Sv"],
|
|
"referenceSpecies": [], "refSpeciesList": [],
|
|
"comparativeSpecies": [], "scopeFile": "",
|
|
}
|
|
|
|
|
|
def test_execute_scope_normalizes_ppi_consumer_to_target_only() -> None:
|
|
request = web_main.ExecuteRequest(
|
|
stage="ppi", job_id="job_ppi_consumer", prefix="Ft.SPL",
|
|
scopeFile="/stale/project.scope.json",
|
|
scopeMode="target_plus_reference", targetSpecies=["Ft"],
|
|
analysisRoute="multi_species",
|
|
memberSetId="SPL.target-Ft.target-reference-At:multi_species:all_members",
|
|
referenceSpecies=["At"], refSpeciesList=["At"], comparativeSpecies=["Os"],
|
|
ppiReferenceTaxonId="3562",
|
|
ppiReferenceStorageKey="string_taxon_3562",
|
|
ppiReferenceUserConfirmed=True,
|
|
dependencyOwner="ppi",
|
|
)
|
|
scope_params = {
|
|
"scopeFile": request.scopeFile,
|
|
"scopeMode": request.scopeMode,
|
|
"targetSpecies": request.targetSpecies,
|
|
"referenceSpecies": request.referenceSpecies,
|
|
"refSpeciesList": request.refSpeciesList,
|
|
"comparativeSpecies": request.comparativeSpecies,
|
|
}
|
|
|
|
technical_refs, ppi_owned_ortholog, needs_fallback = (
|
|
web_main._prepare_execute_technical_reference_scope(request, scope_params)
|
|
)
|
|
|
|
assert technical_refs == []
|
|
assert ppi_owned_ortholog is False
|
|
assert needs_fallback is False
|
|
assert request.analysisRoute == "single_species"
|
|
assert request.memberSetId == ""
|
|
assert scope_params == {
|
|
"scopeFile": "", "scopeMode": "target_only", "targetSpecies": ["Ft"],
|
|
"referenceSpecies": [], "refSpeciesList": [], "comparativeSpecies": [],
|
|
}
|
|
|
|
|
|
def test_execute_scope_keeps_ordinary_ortholog_reference_fallback_enabled() -> None:
|
|
request = web_main.ExecuteRequest(
|
|
stage="ortholog", job_id="job_ordinary_scope", prefix="Sv.HKL",
|
|
scopeMode="target_only", targetSpecies=["Sv"],
|
|
)
|
|
|
|
scope_params = {
|
|
"scopeMode": "target_only", "targetSpecies": ["Sv"],
|
|
"referenceSpecies": [], "refSpeciesList": [],
|
|
}
|
|
technical_refs, ppi_owned_ortholog, needs_fallback = (
|
|
web_main._prepare_execute_technical_reference_scope(request, scope_params)
|
|
)
|
|
|
|
assert technical_refs == []
|
|
assert ppi_owned_ortholog is False
|
|
assert needs_fallback is True
|
|
assert scope_params["scopeMode"] == "target_only"
|
|
|
|
|
|
def test_execute_scope_kaks_ignores_ppi_owner_only_reference_fields() -> None:
|
|
request = web_main.ExecuteRequest(
|
|
stage="kaks",
|
|
job_id="job_kaks_scope",
|
|
prefix="Sv.HKL",
|
|
ppiReferenceTaxonId="4555",
|
|
ppiReferenceStorageKey="string_taxon_4555",
|
|
ppiReferenceUserConfirmed=True,
|
|
dependencyOwner="ppi",
|
|
)
|
|
|
|
scope_params = {
|
|
"scopeMode": "target_plus_reference", "targetSpecies": ["Sv"],
|
|
"referenceSpecies": [], "refSpeciesList": [],
|
|
}
|
|
technical_refs, ppi_owned_ortholog, needs_fallback = (
|
|
web_main._prepare_execute_technical_reference_scope(request, scope_params)
|
|
)
|
|
|
|
assert technical_refs == []
|
|
assert ppi_owned_ortholog is False
|
|
assert needs_fallback is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("confirmed", "storage_key"),
|
|
[(False, "string_taxon_4555"), (True, "")],
|
|
)
|
|
def test_execute_rejects_incomplete_ppi_owned_ortholog_before_scope_or_enqueue(
|
|
monkeypatch, confirmed: bool, storage_key: str,
|
|
) -> None:
|
|
scope_called = False
|
|
|
|
def fail_if_scope_is_built(**_kwargs):
|
|
nonlocal scope_called
|
|
scope_called = True
|
|
raise ValueError("scope builder must not run")
|
|
|
|
monkeypatch.setattr(web_main, "_ensure_gene_family_scope_file", fail_if_scope_is_built)
|
|
request = web_main.ExecuteRequest(
|
|
stage="ortholog", job_id="job_incomplete_owner", prefix="Sv.HKL",
|
|
scopeMode="target_plus_reference", targetSpecies=["Sv"],
|
|
referenceSpecies=["At"], refSpeciesList=["At"],
|
|
ppiKaksReferenceSpecies=["At"], ppiReferenceSpecies="At",
|
|
referenceBundleSpecies="At", ppiReferenceTaxonId="4555",
|
|
ppiReferenceStorageKey=storage_key,
|
|
ppiReferenceUserConfirmed=confirmed, dependencyOwner="ppi",
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(web_main.execute_stage(request))
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert exc_info.value.detail["code"] == "ppi_owner_reference_incomplete"
|
|
assert scope_called is False
|
|
|
|
|
|
def test_execute_normalizes_polluted_confirmed_ppi_owner_before_scope_creation(monkeypatch) -> None:
|
|
captured = {}
|
|
|
|
def capture_scope(**kwargs):
|
|
captured.update(kwargs["params"])
|
|
raise ValueError("captured normalized scope")
|
|
|
|
monkeypatch.setattr(web_main, "_ensure_gene_family_scope_file", capture_scope)
|
|
request = web_main.ExecuteRequest(
|
|
stage="ortholog", job_id="job_confirmed_owner", prefix="Sv.HKL",
|
|
scopeMode="target_plus_reference", targetSpecies=["Sv"],
|
|
referenceSpecies=["At"], refSpeciesList=["At"],
|
|
ppiKaksReferenceSpecies=["At"], ppiReferenceSpecies="At",
|
|
referenceBundleSpecies="At", ppiReferenceTaxonId="4555",
|
|
ppiReferenceStorageKey="string_taxon_4555",
|
|
ppiReferenceUserConfirmed=True, dependencyOwner="ppi",
|
|
)
|
|
|
|
with pytest.raises(HTTPException):
|
|
asyncio.run(web_main.execute_stage(request))
|
|
|
|
assert captured["scopeMode"] == "target_only"
|
|
assert captured["targetSpecies"] == ["Sv"]
|
|
assert captured["referenceSpecies"] == []
|
|
assert captured["refSpeciesList"] == []
|
|
assert [*captured["targetSpecies"], *captured["referenceSpecies"]] == ["Sv"]
|
|
|
|
|
|
def test_execute_preserves_valid_target_only_scope_identity_for_confirmed_ppi_owner(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
) -> None:
|
|
from gfa_core.mas import resource_governor
|
|
from tasks import execute_single_stage_task
|
|
|
|
scope_file = tmp_path / "Sv.HKL.biological.scope.json"
|
|
scope_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"family": "HKL",
|
|
"prefix": "Sv.HKL",
|
|
"scope_id": "fixed-biological-target-only-scope",
|
|
"scope_mode": "target_only",
|
|
"target_species": ["Sv"],
|
|
"reference_species": [],
|
|
"comparative_species": ["Os"],
|
|
"selected_species": ["Sv"],
|
|
"analysis": {"Family": "HKL", "context_marker": "preserve-me"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
scope_called = False
|
|
|
|
def fail_if_scope_is_rebuilt(**_kwargs):
|
|
nonlocal scope_called
|
|
scope_called = True
|
|
raise AssertionError("a validated existing owner scope must be reused")
|
|
|
|
monkeypatch.setattr(web_main, "_ensure_gene_family_scope_file", fail_if_scope_is_rebuilt)
|
|
isolated_apply_async = Mock()
|
|
isolated_apply_async.return_value.id = "isolated-valid-owner-scope"
|
|
monkeypatch.setattr(execute_single_stage_task, "apply_async", isolated_apply_async)
|
|
monkeypatch.setattr(web_main, "_ensure_celery_queue_health", lambda *_args, **_kwargs: ({"available": True}, None))
|
|
monkeypatch.setattr(resource_governor, "record_task", lambda **_kwargs: {})
|
|
request = web_main.ExecuteRequest(
|
|
stage="ortholog", job_id="job_owner_existing_scope", prefix="Sv.HKL",
|
|
scopeFile=str(scope_file), scopeMode="target_plus_reference",
|
|
targetSpecies=["WrongTarget"], referenceSpecies=["At"],
|
|
refSpeciesList=["At"], comparativeSpecies=["Zm"],
|
|
ppiKaksReferenceSpecies=["At"], ppiReferenceSpecies="At",
|
|
referenceBundleSpecies="At", ppiReferenceTaxonId="4555",
|
|
ppiReferenceStorageKey="string_taxon_4555",
|
|
ppiReferenceSelectionMode="tree_nearest",
|
|
ppiReferenceUserConfirmed=True, dependencyOwner="ppi",
|
|
)
|
|
|
|
asyncio.run(web_main.execute_stage(request))
|
|
|
|
assert scope_called is False
|
|
submitted_params = isolated_apply_async.call_args.kwargs["kwargs"]["params"]
|
|
assert submitted_params["scopeFile"] == str(scope_file)
|
|
assert submitted_params["stageContext"]["scope_id"] == "fixed-biological-target-only-scope"
|
|
assert submitted_params["scopeMode"] == "target_only"
|
|
assert submitted_params["analysisRoute"] == "single_species"
|
|
assert submitted_params["memberSetId"] == "fixed-biological-target-only-scope:single_species:all_members"
|
|
assert submitted_params["targetSpecies"] == ["Sv"]
|
|
assert submitted_params["referenceSpecies"] == []
|
|
assert submitted_params["refSpeciesList"] == []
|
|
assert submitted_params["comparativeSpecies"] == ["Os"]
|
|
assert submitted_params["ppiReferenceTaxonId"] == "4555"
|
|
assert submitted_params["ppiReferenceStorageKey"] == "string_taxon_4555"
|
|
assert submitted_params["ppiReferenceSelectionMode"] == "tree_nearest"
|
|
assert submitted_params["ppiReferenceUserConfirmed"] is True
|
|
assert submitted_params["dependencyOwner"] == "ppi"
|
|
assert submitted_params["ppiKaksReferenceSpecies"] == []
|
|
assert submitted_params["ppiReferenceSpecies"] == ""
|
|
assert submitted_params["referenceBundleSpecies"] == ""
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("scope_payload", "expected_code"),
|
|
[
|
|
("{not-json", "ppi_owner_scope_invalid"),
|
|
(
|
|
json.dumps(
|
|
{
|
|
"family": "HKL",
|
|
"scope_id": "wrong-mode-scope",
|
|
"scope_mode": "target_plus_reference",
|
|
"target_species": ["Sv"],
|
|
"reference_species": ["At"],
|
|
"selected_species": ["Sv", "At"],
|
|
}
|
|
),
|
|
"ppi_owner_scope_mode_incompatible",
|
|
),
|
|
],
|
|
)
|
|
def test_execute_fails_closed_for_invalid_ppi_owner_scope_file(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
scope_payload: str,
|
|
expected_code: str,
|
|
) -> None:
|
|
from tasks import execute_single_stage_task
|
|
|
|
scope_file = tmp_path / "owner.scope.json"
|
|
scope_file.write_text(scope_payload, encoding="utf-8")
|
|
scope_called = False
|
|
|
|
def fail_if_scope_is_built(**_kwargs):
|
|
nonlocal scope_called
|
|
scope_called = True
|
|
raise AssertionError("invalid owner scope must fail before scope construction")
|
|
|
|
monkeypatch.setattr(web_main, "_ensure_gene_family_scope_file", fail_if_scope_is_built)
|
|
isolated_apply_async = Mock()
|
|
isolated_apply_async.return_value.id = "must-not-submit-invalid-owner-scope"
|
|
monkeypatch.setattr(execute_single_stage_task, "apply_async", isolated_apply_async)
|
|
request = web_main.ExecuteRequest(
|
|
stage="ortholog", job_id="job_owner_invalid_scope", prefix="Sv.HKL",
|
|
scopeFile=str(scope_file), targetSpecies=["Sv"],
|
|
ppiReferenceTaxonId="4555", ppiReferenceStorageKey="string_taxon_4555",
|
|
ppiReferenceUserConfirmed=True, dependencyOwner="ppi",
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(web_main.execute_stage(request))
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert exc_info.value.detail["code"] == expected_code
|
|
assert scope_called is False
|
|
isolated_apply_async.assert_not_called()
|
|
|
|
|
|
def test_ppi_owner_fields_are_forwarded_without_becoming_global_reference_scope() -> None:
|
|
main_source = Path("apps/web/vue3/main.py").read_text(encoding="utf-8")
|
|
stage_tools_source = Path("gfa_core/mas/tools/stage_tools.py").read_text(encoding="utf-8")
|
|
technical_start = main_source.index("technical_reference_species: list[str] = []")
|
|
technical_end = main_source.index("gene_family_identity_stages =", technical_start)
|
|
technical_block = main_source[technical_start:technical_end]
|
|
|
|
for field in (
|
|
"ppiReferenceTaxonId",
|
|
"ppiReferenceStorageKey",
|
|
"ppiReferenceSelectionMode",
|
|
"ppiReferenceUserConfirmed",
|
|
"dependencyOwner",
|
|
):
|
|
assert f'"{field}": getattr(req' in main_source or f'"{field}": bool(getattr(req' in main_source
|
|
assert field in stage_tools_source
|
|
assert field not in technical_block
|
|
|
|
|
|
def test_frontend_requires_confirmation_and_keeps_ppi_owner_state_isolated() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
assert "const confirmNearestPpiReferenceCandidate = async" in html
|
|
assert "resolution.candidates" in html
|
|
assert "const normalizePpiReferenceCandidates =" in html
|
|
assert 'v-for="(candidate, index) in confirmParams.ppiKaksReferenceCandidates"' in html
|
|
assert "ppiReferenceTaxonId" in html
|
|
assert "ppiReferenceStorageKey" in html
|
|
assert "ppiReferenceSelectionMode" in html
|
|
assert "ppiReferenceUserConfirmed" in html
|
|
assert "dependencyOwner" in html
|
|
assert "preflight.resolution?.status === 'confirmation_required'" in html
|
|
assert "preflight = await runPpiSpeciesResolutionPreflight" in html
|
|
assert "const applyPpiOwnerSelectionToBatch =" in html
|
|
|
|
|
|
def test_confirmation_applies_owner_then_repeats_preflight() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
owner_block = _source_block(
|
|
html,
|
|
"const ensurePpiKaksDependencyOwner = async",
|
|
"const ensureOrthologProducerForPpiKaks = async",
|
|
)
|
|
|
|
assert "preflight.resolution?.status === 'confirmation_required'" in owner_block
|
|
assert "await confirmNearestPpiReferenceCandidate(preflight.resolution)" in owner_block
|
|
assert "applyPpiOwnerSelectionToBatch(ownerSelection" in owner_block
|
|
assert owner_block.count("preflight = await runPpiSpeciesResolutionPreflight") >= 2
|
|
|
|
|
|
def test_ppi_owner_selection_never_mutates_shared_reference_scope() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
selection_block = _source_block(
|
|
html,
|
|
"const applyPpiOwnerSelectionToBatch =",
|
|
"const releaseDirectTargetPpiFromOrtholog =",
|
|
)
|
|
|
|
for field in (
|
|
"ppiReferenceTaxonId",
|
|
"ppiReferenceStorageKey",
|
|
"ppiReferenceSelectionMode",
|
|
"ppiReferenceUserConfirmed",
|
|
"dependencyOwner",
|
|
):
|
|
assert field in selection_block
|
|
assert "getStageId(stage) !== 'ppi'" in selection_block
|
|
assert "ppiKaksReferenceSpecies" not in selection_block
|
|
assert "referenceSpecies:" not in selection_block
|
|
assert "refSpeciesList:" not in selection_block
|
|
assert "scopeMode:" not in selection_block
|
|
|
|
|
|
def test_owner_request_reads_selection_and_allows_kaks_to_reuse_ppi_owner() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
request_block = _source_block(
|
|
html,
|
|
"const buildPpiKaksDependencyRequest =",
|
|
"const attachOrthologDependencyToOwners =",
|
|
)
|
|
owner_block = _source_block(
|
|
html,
|
|
"const ensurePpiKaksDependencyOwner = async",
|
|
"const ensureOrthologProducerForPpiKaks = async",
|
|
)
|
|
|
|
for field in (
|
|
"ppiReferenceTaxonId",
|
|
"ppiReferenceStorageKey",
|
|
"ppiReferenceSelectionMode",
|
|
"ppiReferenceUserConfirmed",
|
|
"dependencyOwner",
|
|
):
|
|
assert field in request_block
|
|
assert "const requestedStages = [ownerStageId]" in owner_block
|
|
assert "const mappingOwner = stageParams.orthologMappingOwner || ownerStageId" in request_block
|
|
assert "dependencyOwner: mappingOwner" in request_block
|
|
assert "ownerStageId === 'ppi'" in request_block
|
|
|
|
|
|
def test_owner_resolution_and_dependency_request_return_live_values() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
selection_block = _source_block(
|
|
html,
|
|
"const ppiOwnerSelectionFromResolution =",
|
|
"const applyPpiOwnerSelectionToBatch =",
|
|
)
|
|
request_block = _source_block(
|
|
html,
|
|
"const buildPpiKaksDependencyRequest =",
|
|
"const ppiKaksInternalOrthologProducers =",
|
|
)
|
|
|
|
assert "return {" in selection_block
|
|
assert "const requestBody = {" in request_block
|
|
assert request_block.index("const requestBody = {") < request_block.index(
|
|
"return isPpiOwner ? canonicalizePpiOwnerBiologicalScope(requestBody) : requestBody"
|
|
)
|
|
assert "return {" not in request_block
|
|
|
|
|
|
def test_download_completion_reapplies_owner_without_shared_merge() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
monitor_block = _source_block(
|
|
html,
|
|
"const monitorPpiReferenceBundleDownload =",
|
|
"const ppiHasNearReferenceBeforeArabidopsis =",
|
|
)
|
|
owner_block = _source_block(
|
|
html,
|
|
"const ensurePpiKaksDependencyOwner = async",
|
|
"const ensureOrthologProducerForPpiKaks = async",
|
|
)
|
|
|
|
assert "applyPpiOwnerSelectionToBatch(ownerSelection" in monitor_block
|
|
assert "recoveryStages.includes('kaks')" in monitor_block
|
|
assert "mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks'])" in monitor_block
|
|
assert "mergePpiReferenceSpeciesIntoBatch(storageKey, recoveryStages)" not in monitor_block
|
|
assert "applyPpiOwnerSelectionToBatch(ownerSelection" in owner_block
|
|
|
|
|
|
def test_kaks_download_success_uses_storage_key_and_recovers_shared_ppi_pause() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
recovery_helper = _source_block(
|
|
html,
|
|
"const ppiKaksFallbackRecoveryOwners =",
|
|
"const handlePpiReferenceDownloadFailure =",
|
|
)
|
|
monitor = _source_block(
|
|
html,
|
|
"const monitorPpiReferenceBundleDownload =",
|
|
"const ppiHasNearReferenceBeforeArabidopsis =",
|
|
)
|
|
result = _run_node(
|
|
recovery_helper
|
|
+ monitor
|
|
+ """
|
|
const calls = {applied: [], merged: [], orchestrated: [], resumed: []};
|
|
const batchExecuteStages = {value: [
|
|
{id: 'ortholog'},
|
|
{id: 'kaks', dependencies: ['ortholog']},
|
|
{id: 'ppi', dependencies: ['ortholog']}
|
|
]};
|
|
const stageExecutionStatus = {value: {ortholog: 'paused', kaks: 'paused', ppi: 'paused'}};
|
|
const stagePauseReasons = {value: {
|
|
ppi: {type: 'dependency_paused', reason: 'upstream_stage_paused'}
|
|
}};
|
|
const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
|
|
const updateReferenceBundleDownloadProgress = () => {};
|
|
const pollReferenceBundleDownloadTask = async () => ({
|
|
success: true,
|
|
species_code: 'string_taxon_3562',
|
|
species_name: 'Spinacia oleracea',
|
|
taxon_id: '3562',
|
|
requested_stages: ['kaks'],
|
|
files: {
|
|
pep: '/job/string_taxon_3562/string_taxon_3562.pep.fasta',
|
|
cds: '/job/string_taxon_3562/string_taxon_3562.cds.fasta'
|
|
}
|
|
});
|
|
const ppiOwnerSelectionFromResolution = resolution => ({
|
|
taxonId: resolution.selected_taxon_id,
|
|
storageKey: resolution.ppi_reference_storage_key,
|
|
selectionMode: 'tree_nearest',
|
|
userConfirmed: resolution.ppi_reference_user_confirmed
|
|
});
|
|
const applyPpiOwnerSelectionToBatch = selection => calls.applied.push(selection);
|
|
const mergePpiReferenceSpeciesIntoBatch = (code, stages) => calls.merged.push({code, stages});
|
|
const clearReferenceBundleDownloadProgress = () => {};
|
|
const addAgentMessage = () => {};
|
|
const buildPpiKaksDependencyRequest = stages => ({requestedStages: stages});
|
|
const runPpiSpeciesResolutionPreflight = async () => ({allowed: true});
|
|
const orchestratePpiOwnerAfterReferenceReady = async options => {
|
|
calls.orchestrated.push(options.requestedStages);
|
|
return true;
|
|
};
|
|
const resumePpiKaksPausedStages = async stages => calls.resumed.push(stages);
|
|
const waitForPpiProducerDependency = async () => true;
|
|
const ensureOrthologStageForPpiKaks = () => true;
|
|
const executePpiOwnedOrthologProducer = async () => true;
|
|
const handlePpiReferenceDownloadFailure = async () => false;
|
|
|
|
monitorPpiReferenceBundleDownload(
|
|
'task-3562',
|
|
{species_code: 'soe', species_name: 'Spinacia oleracea', selected_string_taxon_id: '3562'},
|
|
['kaks'],
|
|
{ppi_reference_storage_key: 'string_taxon_3562'}
|
|
);
|
|
setTimeout(() => console.log(JSON.stringify(calls)), 20);
|
|
"""
|
|
)
|
|
|
|
assert result["merged"] == [{"code": "string_taxon_3562", "stages": ["kaks"]}]
|
|
assert result["applied"] == [{
|
|
"taxonId": "3562",
|
|
"storageKey": "string_taxon_3562",
|
|
"selectionMode": "tree_nearest",
|
|
"userConfirmed": True,
|
|
}]
|
|
assert result["orchestrated"] == [["ppi"]]
|
|
assert result["resumed"] == [["kaks"]]
|
|
|
|
|
|
def test_immediate_download_success_uses_storage_key_and_shared_pause_recovery() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
owner_block = _source_block(
|
|
html,
|
|
"const ensurePpiKaksDependencyOwner = async",
|
|
"const ensureOrthologProducerForPpiKaks = async",
|
|
)
|
|
|
|
assert "const storageKey = result.species_code || preflight.resolution.ppi_reference_storage_key" in owner_block
|
|
assert "const recoveryStages = ppiKaksFallbackRecoveryOwners(" in owner_block
|
|
assert "mergePpiReferenceSpeciesIntoBatch(storageKey, ['kaks'])" in owner_block
|
|
assert "if (recoveryStages.includes('ppi'))" in owner_block
|
|
assert "if (recoveryStages.includes('kaks'))" in owner_block
|
|
|
|
|
|
def test_arabidopsis_fallback_is_unselected_until_explicit_confirmation() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
fallback_request = _source_block(
|
|
html,
|
|
"const requestPpiDownloadFallbackDecision =",
|
|
"const clearPpiKaksReferencePause =",
|
|
)
|
|
|
|
assert "defaultDecision: 'skip'" in fallback_request
|
|
assert "confirmParams.ppiKaksFallbackDecision: 'skip'" not in html
|
|
assert "ppiKaksFallbackDecision: 'skip'" in html
|
|
assert "confirmParams.ppiKaksFallbackDecision = 'skip'" in html
|
|
|
|
|
|
def test_arabidopsis_fallback_does_not_preselect_kaks_reference_route() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
fallback = _source_block(
|
|
html,
|
|
"const applyArabidopsisFallbackForPpiKaks =",
|
|
"const resumePpiKaksPausedStages =",
|
|
)
|
|
|
|
assert "kaksReferenceSource: 'project'" not in fallback
|
|
assert "referenceSpecies: ['At']" not in fallback
|
|
assert "orthologMappingOwner: 'kaks'" not in fallback
|
|
|
|
|
|
def test_kaks_download_failure_recovers_only_ppi_paused_on_same_ortholog() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper_marker = "const ppiKaksFallbackRecoveryOwners ="
|
|
helper = (
|
|
_source_block(html, helper_marker, "const handlePpiReferenceDownloadFailure =")
|
|
if helper_marker in html
|
|
else ""
|
|
)
|
|
handler = _source_block(
|
|
html,
|
|
"const handlePpiReferenceDownloadFailure =",
|
|
"const downloadRecommendedPpiReferenceBundle =",
|
|
)
|
|
result = _run_node(
|
|
_ppi_scope_js()
|
|
+ helper
|
|
+ handler
|
|
+ """
|
|
const runCase = async ({stages, statuses, pauses}) => {
|
|
const calls = {dialog: null, fallback: null, orchestrated: [], resumed: []};
|
|
batchExecuteStages.value = stages;
|
|
stageExecutionStatus.value = statuses;
|
|
stagePauseReasons.value = pauses;
|
|
requestPpiDownloadFallbackDecision = async (_err, _resolution, _recommendation, owners) => {
|
|
calls.dialog = owners;
|
|
return 'ath';
|
|
};
|
|
applyArabidopsisFallbackForPpiKaks = owners => { calls.fallback = owners; };
|
|
orchestratePpiOwnerAfterReferenceReady = async options => {
|
|
calls.orchestrated.push(options.requestedStages);
|
|
return true;
|
|
};
|
|
resumePpiKaksPausedStages = async owners => { calls.resumed.push(owners); };
|
|
await handlePpiReferenceDownloadFailure(new Error('download failed'), {}, {}, ['kaks']);
|
|
return calls;
|
|
};
|
|
|
|
let batchExecuteStages = {value: []};
|
|
let stageExecutionStatus = {value: {}};
|
|
let stagePauseReasons = {value: {}};
|
|
let requestPpiDownloadFallbackDecision;
|
|
let applyArabidopsisFallbackForPpiKaks;
|
|
let orchestratePpiOwnerAfterReferenceReady;
|
|
let resumePpiKaksPausedStages;
|
|
const addAgentMessage = () => {};
|
|
const buildPpiKaksDependencyRequest = () => ({});
|
|
const runPpiSpeciesResolutionPreflight = async () => ({allowed: true});
|
|
const waitForPpiProducerDependency = async () => true;
|
|
const ensureOrthologStageForPpiKaks = () => true;
|
|
const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
|
|
const executePpiOwnedOrthologProducer = async () => true;
|
|
const storePpiKaksReferencePause = () => {};
|
|
|
|
(async () => {
|
|
const jointPaused = await runCase({
|
|
stages: [
|
|
{id: 'ortholog'},
|
|
{id: 'kaks', dependencies: ['ortholog']},
|
|
{id: 'ppi', dependencies: ['ortholog']}
|
|
],
|
|
statuses: {ortholog: 'paused', kaks: 'paused', ppi: 'paused'},
|
|
pauses: {ppi: {type: 'dependency_paused', reason: 'upstream_stage_paused'}}
|
|
});
|
|
const completedPpi = await runCase({
|
|
stages: [{id: 'ortholog'}, {id: 'kaks'}, {id: 'ppi', dependencies: ['ortholog']}],
|
|
statuses: {ortholog: 'paused', kaks: 'paused', ppi: 'completed'},
|
|
pauses: {ppi: {type: 'dependency_paused', reason: 'upstream_stage_paused'}}
|
|
});
|
|
const kaksOnly = await runCase({
|
|
stages: [{id: 'ortholog'}, {id: 'kaks', dependencies: ['ortholog']}],
|
|
statuses: {ortholog: 'paused', kaks: 'paused'},
|
|
pauses: {}
|
|
});
|
|
console.log(JSON.stringify({jointPaused, completedPpi, kaksOnly}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result["jointPaused"] == {
|
|
"dialog": ["kaks", "ppi"],
|
|
"fallback": ["ppi"],
|
|
"orchestrated": [["ppi"]],
|
|
"resumed": [],
|
|
}
|
|
for guarded_case in (result["completedPpi"], result["kaksOnly"]):
|
|
assert guarded_case == {
|
|
"dialog": ["kaks"],
|
|
"fallback": None,
|
|
"orchestrated": [],
|
|
"resumed": [],
|
|
}
|
|
|
|
|
|
def test_final_ppi_request_dynamically_uses_target_only_biological_scope() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(html, "const canonicalizePpiOwnerBiologicalScope =", "const applyGeneFamilyContextToRequest =")
|
|
result = _run_node(
|
|
_ppi_scope_js()
|
|
+ helper
|
|
+ """
|
|
const request = {
|
|
geneFamily: 'SPL', targetSpecies: ['Ft'], comparativeSpecies: ['Os'],
|
|
analysisRoute: 'multi_species', memberSetId: 'stale-member', scopeId: 'stale-scope', scopeFile: '/stale/scope.json',
|
|
referenceSpecies: ['ProjectRef'],
|
|
refSpeciesList: ['ProjectRef'],
|
|
scopeMode: 'target_plus_reference',
|
|
ppiKaksReferenceSpecies: ['KaKsRef'],
|
|
ppiReferenceSpecies: 'KaKsRef',
|
|
referenceBundleSpecies: 'KaKsRef'
|
|
};
|
|
const batch = {
|
|
ppiReferenceTaxonId: '4555',
|
|
ppiReferenceStorageKey: 'string_taxon_4555',
|
|
ppiReferenceSelectionMode: 'tree_nearest',
|
|
ppiReferenceUserConfirmed: true,
|
|
dependencyOwner: 'ppi'
|
|
};
|
|
console.log(JSON.stringify(applyPpiOwnerFieldsToRequest(request, 'ppi', {}, batch, {})));
|
|
"""
|
|
)
|
|
|
|
assert result["ppiReferenceTaxonId"] == "4555"
|
|
assert result["ppiReferenceStorageKey"] == "string_taxon_4555"
|
|
assert result["ppiReferenceSelectionMode"] == "tree_nearest"
|
|
assert result["ppiReferenceUserConfirmed"] is True
|
|
assert result["dependencyOwner"] == "ppi"
|
|
assert result["targetSpecies"] == ["Ft"]
|
|
assert result["referenceSpecies"] == []
|
|
assert result["refSpeciesList"] == []
|
|
assert result["comparativeSpecies"] == []
|
|
assert result["scopeMode"] == "target_only"
|
|
assert result["analysisRoute"] == "single_species"
|
|
assert result["scopeId"] == "SPL.target-Ft.target_only"
|
|
assert result["memberSetId"] == "SPL.target-Ft.target_only:single_species:all_members"
|
|
assert result["scopeFile"] == ""
|
|
for forbidden in ("ppiKaksReferenceSpecies", "ppiReferenceSpecies", "referenceBundleSpecies"):
|
|
assert forbidden not in result
|
|
|
|
|
|
def test_joint_batch_ppi_request_dynamically_ignores_kaks_reference() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(html, "const canonicalizePpiOwnerBiologicalScope =", "const applyGeneFamilyContextToRequest =")
|
|
result = _run_node(
|
|
_ppi_scope_js()
|
|
+ helper
|
|
+ """
|
|
const request = { geneFamily: 'SPL', targetSpecies: ['Ft'], referenceSpecies: ['ProjectRef'], refSpeciesList: ['ProjectRef'] };
|
|
const batch = {
|
|
ppiKaksReferenceSpecies: ['KaKsRef'],
|
|
ppiReferenceSpecies: 'KaKsRef',
|
|
referenceBundleSpecies: 'KaKsRef',
|
|
ppiReferenceTaxonId: '4555',
|
|
ppiReferenceStorageKey: 'string_taxon_4555',
|
|
ppiReferenceSelectionMode: 'tree_nearest',
|
|
ppiReferenceUserConfirmed: true,
|
|
dependencyOwner: 'ppi'
|
|
};
|
|
console.log(JSON.stringify(applyPpiOwnerFieldsToRequest(request, 'ppi', {}, batch, {})));
|
|
"""
|
|
)
|
|
|
|
assert result["referenceSpecies"] == []
|
|
assert "KaKsRef" not in json.dumps(result)
|
|
assert result["ppiReferenceTaxonId"] == "4555"
|
|
|
|
|
|
def test_reference_ready_orchestration_dynamically_inserts_ortholog_before_resume() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const orchestratePpiOwnerAfterReferenceReady =",
|
|
"const monitorPpiReferenceBundleDownload =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
(async () => {
|
|
const calls = [];
|
|
let preflightCount = 0;
|
|
const ok = await orchestratePpiOwnerAfterReferenceReady({
|
|
requestBody: { stage: 'ppi' },
|
|
requestedStages: ['ppi'],
|
|
runPreflight: async () => {
|
|
calls.push('preflight');
|
|
preflightCount += 1;
|
|
return { allowed: true, resolution: { status: preflightCount === 1 ? 'needs_ortholog' : 'ready' } };
|
|
},
|
|
ensureOrtholog: async () => { calls.push('ortholog'); return true; },
|
|
resume: async () => { calls.push('resume'); }
|
|
});
|
|
console.log(JSON.stringify({ ok, calls }));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"ok": True, "calls": ["preflight", "ortholog", "preflight", "resume"]}
|
|
|
|
|
|
def test_ppi_owned_ortholog_uses_owner_storage_without_polluting_final_ppi_request() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
producer_helper = _source_block(
|
|
html,
|
|
"const buildPpiOwnedOrthologStage =",
|
|
"const attachOrthologDependencyToOwners =",
|
|
)
|
|
request_helper = _source_block(html, "const canonicalizePpiOwnerBiologicalScope =", "const applyGeneFamilyContextToRequest =")
|
|
ortholog_request_helper = _source_block(
|
|
html,
|
|
"const applyPpiOwnedOrthologFieldsToRequest =",
|
|
"const canonicalizePpiOwnerBiologicalScope =",
|
|
)
|
|
direct_builder = _source_block(
|
|
html,
|
|
"const finalizeExecuteStageDirectRequest =",
|
|
"const executeStageDirect = async",
|
|
)
|
|
result = _run_node(
|
|
_ppi_scope_js()
|
|
+ producer_helper
|
|
+ ortholog_request_helper
|
|
+ request_helper
|
|
+ direct_builder
|
|
+ """
|
|
const request = {
|
|
geneFamily: 'SPL', targetSpecies: ['Ft'],
|
|
referenceSpecies: ['At'], refSpeciesList: ['At'], scopeMode: 'target_plus_reference',
|
|
ppiKaksReferenceSpecies: ['KaKsRef'], ppiReferenceSpecies: 'KaKsRef', referenceBundleSpecies: 'KaKsRef'
|
|
};
|
|
const owner = {
|
|
ppiReferenceTaxonId: '4555', ppiReferenceStorageKey: 'string_taxon_4555',
|
|
ppiReferenceSelectionMode: 'tree_nearest', ppiReferenceUserConfirmed: true, dependencyOwner: 'ppi'
|
|
};
|
|
const ortholog = buildPpiOwnedOrthologStage(request, owner, ['ppi']);
|
|
const finalOrtholog = applyPpiOwnedOrthologFieldsToRequest(
|
|
{...ortholog.params, ppiKaksReferenceSpecies: ['KaKsRef'], referenceSpecies: ['KaKsRef']},
|
|
'ortholog', ortholog.params
|
|
);
|
|
const directOrtholog = finalizeExecuteStageDirectRequest(
|
|
{stage: 'ortholog', referenceSpecies: ['KaKsRef'], ppiKaksReferenceSpecies: ['KaKsRef']},
|
|
'ortholog', ortholog.params
|
|
);
|
|
const ppi = applyPpiOwnerFieldsToRequest({...request}, 'ppi', {}, owner, {});
|
|
console.log(JSON.stringify({ortholog, finalOrtholog, directOrtholog, ppi}));
|
|
"""
|
|
)
|
|
|
|
assert result["ortholog"]["params"]["ppiReferenceTaxonId"] == "4555"
|
|
expected_owner = {
|
|
"ppiReferenceTaxonId": "4555",
|
|
"ppiReferenceStorageKey": "string_taxon_4555",
|
|
"ppiReferenceSelectionMode": "tree_nearest",
|
|
"ppiReferenceUserConfirmed": True,
|
|
"dependencyOwner": "ppi",
|
|
}
|
|
for payload in (result["ortholog"]["params"], result["finalOrtholog"], result["directOrtholog"]):
|
|
for field, expected in expected_owner.items():
|
|
assert payload[field] == expected
|
|
for forbidden in (
|
|
"referenceSpecies", "refSpeciesList", "scopeMode",
|
|
"ppiKaksReferenceSpecies", "ppiReferenceSpecies", "referenceBundleSpecies",
|
|
):
|
|
assert forbidden not in payload
|
|
assert result["directOrtholog"]["requestedStages"] == ["ppi"]
|
|
assert result["directOrtholog"]["autoInsertedFor"] == ["ppi"]
|
|
assert result["ppi"]["referenceSpecies"] == []
|
|
assert result["ppi"]["refSpeciesList"] == []
|
|
assert result["ppi"]["scopeMode"] == "target_only"
|
|
assert result["ppi"]["scopeId"] == "SPL.target-Ft.target_only"
|
|
for forbidden in ("ppiKaksReferenceSpecies", "ppiReferenceSpecies", "referenceBundleSpecies"):
|
|
assert forbidden not in result["ppi"]
|
|
|
|
|
|
def test_active_scheduler_executes_inserted_ppi_owned_ortholog_before_resume() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const orchestratePpiOwnerAfterReferenceReady =",
|
|
"const monitorPpiReferenceBundleDownload =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
(async () => {
|
|
const calls = [];
|
|
let preflightCount = 0;
|
|
const producer = { id: 'ortholog', params: { ppiReferenceStorageKey: 'string_taxon_4555' } };
|
|
const ok = await orchestratePpiOwnerAfterReferenceReady({
|
|
requestBody: { stage: 'ppi' }, requestedStages: ['ppi'], isBatchExecuting: true,
|
|
runPreflight: async () => {
|
|
calls.push('preflight');
|
|
preflightCount += 1;
|
|
return {allowed: true, resolution: {status: preflightCount === 1 ? 'needs_ortholog' : 'ready'}};
|
|
},
|
|
ensureOrtholog: async () => { calls.push('ensure'); return {ready: true, producerStage: producer}; },
|
|
executeProducer: async stage => { calls.push(`execute:${stage.id}`); return true; },
|
|
resume: async () => { calls.push('resume'); }
|
|
});
|
|
console.log(JSON.stringify({ok, calls}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"ok": True,
|
|
"calls": ["preflight", "ensure", "execute:ortholog", "preflight", "resume"],
|
|
}
|
|
|
|
|
|
def test_kaks_soft_gate_releases_only_after_ppi_settles() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const ppiSettledForKaks =",
|
|
"const executeStagesWithDependencies = async",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const stages = [{id: 'ppi'}, {id: 'kaks'}];
|
|
const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
|
|
console.log(JSON.stringify({
|
|
pending: ppiSettledForKaks(stages, {ppi: 'pending'}),
|
|
running: ppiSettledForKaks(stages, {ppi: 'running'}),
|
|
paused: ppiSettledForKaks(stages, {ppi: 'paused'}),
|
|
completed: ppiSettledForKaks(stages, {ppi: 'completed'}),
|
|
failed: ppiSettledForKaks(stages, {ppi: 'failed'}),
|
|
noPpi: ppiSettledForKaks([{id: 'kaks'}], {})
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"pending": False,
|
|
"running": False,
|
|
"paused": False,
|
|
"completed": True,
|
|
"failed": True,
|
|
"noPpi": True,
|
|
}
|
|
|
|
|
|
def test_kaks_reference_choice_keeps_ppi_and_project_namespaces_separate() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const kaksReferenceChoiceParams =",
|
|
"const confirmedStageParamsForExecution =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
console.log(JSON.stringify({
|
|
ppi: kaksReferenceChoiceParams('ppi', 'string_taxon_3562', 'At'),
|
|
project: kaksReferenceChoiceParams('project', 'string_taxon_3562', 'At')
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result["ppi"] == {
|
|
"kaksReferenceSource": "ppi",
|
|
"referenceSpecies": ["string_taxon_3562"],
|
|
"refSpeciesList": ["string_taxon_3562"],
|
|
"ppiKaksReferenceSpecies": ["string_taxon_3562"],
|
|
"ppiReferenceSpecies": "string_taxon_3562",
|
|
"referenceBundleSpecies": "string_taxon_3562",
|
|
"orthologMappingOwner": "ppi",
|
|
}
|
|
assert result["project"] == {
|
|
"kaksReferenceSource": "project",
|
|
"referenceSpecies": ["At"],
|
|
"refSpeciesList": ["At"],
|
|
"ppiKaksReferenceSpecies": [],
|
|
"ppiReferenceSpecies": "",
|
|
"referenceBundleSpecies": "",
|
|
"orthologMappingOwner": "kaks",
|
|
}
|
|
|
|
|
|
def test_kaks_preflight_uses_selected_ppi_mapping_owner() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const buildPpiKaksDependencyRequest =",
|
|
"const ppiKaksInternalOrthologProducers =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const batchExecuteParams = {value: {
|
|
prefix: 'Ft.SPL',
|
|
ppiReferenceTaxonId: '3562',
|
|
ppiReferenceStorageKey: 'string_taxon_3562',
|
|
ppiReferenceSelectionMode: 'tree_nearest',
|
|
ppiReferenceUserConfirmed: true
|
|
}};
|
|
const batchExecuteStages = {value: [{
|
|
id: 'kaks',
|
|
params: {
|
|
kaksReferenceSource: 'project',
|
|
orthologMappingOwner: 'kaks',
|
|
referenceSpecies: ['At'],
|
|
refSpeciesList: ['At']
|
|
}
|
|
}]};
|
|
const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
|
|
const firstNonEmptyList = (...values) => {
|
|
for (const value of values) {
|
|
if (Array.isArray(value) && value.length) return [...value];
|
|
if (typeof value === 'string' && value.trim()) return [value.trim()];
|
|
}
|
|
return [];
|
|
};
|
|
const params = {prefix: 'Ft.SPL', geneFamily: 'SPL'};
|
|
const confirmParams = {};
|
|
const executionPrefix = () => 'Ft.SPL';
|
|
const canonicalizePpiOwnerBiologicalScope = value => value;
|
|
const confirmedStageParams = {
|
|
kaksReferenceSource: 'ppi',
|
|
orthologMappingOwner: 'ppi',
|
|
referenceSpecies: ['string_taxon_3562'],
|
|
refSpeciesList: ['string_taxon_3562'],
|
|
ppiKaksReferenceSpecies: ['string_taxon_3562'],
|
|
ppiReferenceSpecies: 'string_taxon_3562',
|
|
referenceBundleSpecies: 'string_taxon_3562'
|
|
};
|
|
console.log(JSON.stringify(
|
|
buildPpiKaksDependencyRequest(['kaks'], confirmedStageParams)
|
|
));
|
|
"""
|
|
)
|
|
|
|
assert {
|
|
"dependencyOwner": result["dependencyOwner"],
|
|
"requestedStages": result["requestedStages"],
|
|
"referenceSpecies": result["referenceSpecies"],
|
|
"ppiReferenceTaxonId": result["ppiReferenceTaxonId"],
|
|
"ppiReferenceStorageKey": result["ppiReferenceStorageKey"],
|
|
"ppiReferenceSelectionMode": result["ppiReferenceSelectionMode"],
|
|
"ppiReferenceUserConfirmed": result["ppiReferenceUserConfirmed"],
|
|
} == {
|
|
"dependencyOwner": "ppi",
|
|
"requestedStages": ["kaks"],
|
|
"referenceSpecies": ["string_taxon_3562"],
|
|
"ppiReferenceTaxonId": "3562",
|
|
"ppiReferenceStorageKey": "string_taxon_3562",
|
|
"ppiReferenceSelectionMode": "tree_nearest",
|
|
"ppiReferenceUserConfirmed": True,
|
|
}
|
|
|
|
|
|
def test_kaks_project_at_route_is_not_blocked_by_ppi_near_reference_policy() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const ppiHasNearReferenceBeforeArabidopsis =",
|
|
"const runPpiSpeciesResolutionPreflight =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const resolution = {
|
|
status: 'needs_ortholog',
|
|
selected_string_species: 'ath',
|
|
diagnostics: {
|
|
reference_recommendations: [{species_code: 'string_taxon_3562'}]
|
|
}
|
|
};
|
|
console.log(JSON.stringify({
|
|
ppi: ppiNeedsNearReferenceDecision(resolution, ['ppi']),
|
|
kaks: ppiNeedsNearReferenceDecision(resolution, ['kaks'])
|
|
}));
|
|
"""
|
|
)
|
|
|
|
assert result == {"ppi": True, "kaks": False}
|
|
|
|
|
|
def test_prepare_kaks_preflights_the_exact_confirmed_stage_params() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const prepareKaksSelectedReference =",
|
|
"const submitExecuteRequestWithAutoAnnotation =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const confirmedStageParams = {
|
|
kaksReferenceSource: 'ppi',
|
|
orthologMappingOwner: 'ppi',
|
|
referenceSpecies: ['string_taxon_3562'],
|
|
ppiKaksReferenceSpecies: ['string_taxon_3562']
|
|
};
|
|
let captured = null;
|
|
const ensurePpiKaksDependencyOwner = async (...args) => {
|
|
captured = args;
|
|
return true;
|
|
};
|
|
const ppiKaksDependencyPauses = () => ({});
|
|
|
|
(async () => {
|
|
const ready = await prepareKaksSelectedReference(confirmedStageParams);
|
|
console.log(JSON.stringify({ready, captured}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"ready": True,
|
|
"captured": [
|
|
"kaks",
|
|
{
|
|
"kaksReferenceSource": "ppi",
|
|
"orthologMappingOwner": "ppi",
|
|
"referenceSpecies": ["string_taxon_3562"],
|
|
"ppiKaksReferenceSpecies": ["string_taxon_3562"],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
def test_submit_preflight_maps_kaks_selected_ortholog_owner_to_dependency_owner() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
retry_helper = _source_block(
|
|
html,
|
|
"const fetchWithTransientRetry = async",
|
|
"const pollReferenceBundleDownloadTask = async",
|
|
)
|
|
helper = _source_block(
|
|
html,
|
|
"const runPpiSpeciesResolutionPreflight =",
|
|
"const ppiKaksDependencyStages =",
|
|
)
|
|
result = _run_node(
|
|
retry_helper
|
|
+ helper
|
|
+ """
|
|
const jobInfo = {job_id: 'job_1786243816439_04955b'};
|
|
const executionPrefix = () => 'Ft.SPL';
|
|
let capturedPayload = null;
|
|
const fetch = async (_url, options) => {
|
|
capturedPayload = JSON.parse(options.body);
|
|
return {
|
|
ok: true,
|
|
json: async () => ({success: true, status: 'ready'})
|
|
};
|
|
};
|
|
|
|
(async () => {
|
|
const outcome = await runPpiSpeciesResolutionPreflight({
|
|
stage: 'kaks',
|
|
prefix: 'Ft.SPL',
|
|
orthologMappingOwner: 'ppi',
|
|
targetSpecies: ['Ft'],
|
|
referenceSpecies: ['string_taxon_3562']
|
|
}, {requestedStages: ['kaks']});
|
|
console.log(JSON.stringify({
|
|
allowed: outcome.allowed,
|
|
dependencyOwner: capturedPayload.dependencyOwner
|
|
}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"allowed": True, "dependencyOwner": "ppi"}
|
|
|
|
|
|
def test_at_fallback_owner_orchestration_failure_stays_paused_and_returns_false() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
handler = _source_block(
|
|
html,
|
|
"const handlePpiReferenceDownloadFailure =",
|
|
"const downloadRecommendedPpiReferenceBundle =",
|
|
)
|
|
result = _run_node(
|
|
handler
|
|
+ """
|
|
const batchExecuteStages = {value: [{id: 'ppi', params: {}}]};
|
|
const stageExecutionStatus = {value: {ppi: 'paused'}};
|
|
const stagePauseReasons = {value: {}};
|
|
const pauses = [];
|
|
const ppiKaksFallbackRecoveryOwners = () => ['ppi'];
|
|
const requestPpiDownloadFallbackDecision = async () => 'ath';
|
|
const applyArabidopsisFallbackForPpiKaks = () => {};
|
|
const buildPpiKaksDependencyRequest = () => ({stage: 'ppi'});
|
|
const orchestratePpiOwnerAfterReferenceReady = async () => false;
|
|
const runPpiSpeciesResolutionPreflight = async () => ({allowed: true});
|
|
const waitForPpiProducerDependency = async () => true;
|
|
const ensureOrthologStageForPpiKaks = () => true;
|
|
const getPpiKaksInternalOrthologProducer = () => null;
|
|
const executePpiOwnedOrthologProducer = async () => false;
|
|
const resumePpiKaksPausedStages = async () => {};
|
|
const addAgentMessage = () => {};
|
|
const storePpiKaksReferencePause = (owners, resolution, message) => {
|
|
pauses.push({owners, resolution, message});
|
|
};
|
|
|
|
(async () => {
|
|
const accepted = await handlePpiReferenceDownloadFailure(
|
|
new Error('download failed'),
|
|
{},
|
|
{},
|
|
['ppi']
|
|
);
|
|
console.log(JSON.stringify({accepted, pauses}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result["accepted"] is False
|
|
assert result["pauses"] == [
|
|
{
|
|
"owners": ["ppi"],
|
|
"resolution": {},
|
|
"message": "拟南芥兜底参考尚未形成可用的 PPI Ortholog 映射;PPI 保持暂停。",
|
|
}
|
|
]
|
|
|
|
|
|
def test_kaks_missing_reference_data_does_not_open_download_decision() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const ensurePpiKaksDependencyOwner = async",
|
|
"const preflightPpiKaksDependencyOwners = async",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const batchExecuteStages = {value: [{id: 'kaks', params: {orthologMappingOwner: 'ppi'}}]};
|
|
const ppiKaksDependencyStages = new Set(['ppi', 'kaks']);
|
|
const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
|
|
const buildPpiKaksDependencyRequest = () => ({stage: 'kaks'});
|
|
const runPpiSpeciesResolutionPreflight = async () => ({
|
|
allowed: false,
|
|
resolution: {status: 'needs_reference_data', issue_code: 'kaks_reference_cds_missing'}
|
|
});
|
|
const ppiNeedsNearReferenceDecision = () => false;
|
|
let decisionCalls = 0;
|
|
const requestPpiReferenceDataDecision = async () => { decisionCalls += 1; return 'skip'; };
|
|
const formatPpiSpeciesResolutionBlockMessage = () => 'reference data missing';
|
|
const storePpiKaksReferencePause = () => {};
|
|
const addAgentMessage = () => {};
|
|
|
|
(async () => {
|
|
const ready = await ensurePpiKaksDependencyOwner('kaks');
|
|
console.log(JSON.stringify({ready, decisionCalls}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"ready": False, "decisionCalls": 0}
|
|
|
|
|
|
def test_orchestrator_waits_for_identify_and_does_not_resume_on_producer_failure() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const orchestratePpiOwnerAfterReferenceReady =",
|
|
"const monitorPpiReferenceBundleDownload =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
(async () => {
|
|
const calls = [];
|
|
let identify = 'running';
|
|
const base = {
|
|
requestBody: {stage: 'ppi'}, requestedStages: ['ppi'],
|
|
runPreflight: async () => {
|
|
calls.push('preflight');
|
|
return {allowed: true, resolution: {status: calls.filter(item => item === 'preflight').length <= 2 ? 'needs_ortholog' : 'ready'}};
|
|
},
|
|
dependencyStatus: () => identify,
|
|
waitForDependency: async () => { calls.push('wait:identify'); identify = 'completed'; return true; },
|
|
ensureOrtholog: async () => ({ready: true, producerStage: {id: 'ortholog'}}),
|
|
resume: async () => { calls.push('resume'); }
|
|
};
|
|
const failed = await orchestratePpiOwnerAfterReferenceReady({
|
|
...base, executeProducer: async () => { calls.push('execute:failed'); return false; }
|
|
});
|
|
const succeeded = await orchestratePpiOwnerAfterReferenceReady({
|
|
...base, executeProducer: async () => { calls.push('execute:success'); return true; }
|
|
});
|
|
console.log(JSON.stringify({failed, succeeded, calls}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"failed": False,
|
|
"succeeded": True,
|
|
"calls": ["preflight", "wait:identify", "execute:failed", "preflight", "execute:success", "preflight", "resume"],
|
|
}
|
|
|
|
|
|
def test_ppi_stage_error_path_keeps_confirmed_params_in_catch_scope_and_batch_has_final_cleanup() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
stage_start = html.index("const executeStageWithConfirmation = async")
|
|
stage_end = html.index("const NCBI_SPECIES_DEFAULTS =", stage_start)
|
|
stage_block = html[stage_start:stage_end]
|
|
try_index = stage_block.index("try {")
|
|
declaration_index = stage_block.index("let confirmedStageParams")
|
|
catch_index = stage_block.index("} catch (err)")
|
|
|
|
assert declaration_index < try_index < catch_index
|
|
assert "confirmedStageParams = confirmedStageParamsForExecution" in stage_block
|
|
assert "stageExecutionStatus.value[stageId] = 'failed'" in stage_block[catch_index:]
|
|
|
|
batch_start = html.index("const startBatchExecute = async")
|
|
batch_end = html.index("const ppiSettledForKaks =", batch_start)
|
|
batch_block = html[batch_start:batch_end]
|
|
execute_index = batch_block.index("await executeStagesWithDependencies")
|
|
finally_index = batch_block.index("finally", execute_index)
|
|
cleanup_index = batch_block.index("isBatchExecuting.value = false", finally_index)
|
|
assert execute_index < finally_index < cleanup_index
|
|
|
|
|
|
def test_hidden_ppi_ortholog_reports_success_to_the_user() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
block = _source_block(
|
|
html,
|
|
"const executePpiOwnedOrthologProducer = async",
|
|
"const waitForPpiProducerDependency = async",
|
|
)
|
|
assert "专属同源映射生成完成" in block
|
|
|
|
|
|
def test_arabidopsis_fallback_keeps_ppi_paused_until_owner_ortholog_is_ready() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const applyArabidopsisFallbackForPpiKaks =",
|
|
"const resumePpiKaksPausedStages =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const calls = {selection: null, clear: []};
|
|
const batchExecuteStages = {value: [{id: 'ppi', params: {}}]};
|
|
const stageExecutionStatus = {value: {ppi: 'paused'}};
|
|
const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
|
|
const getStageLabel = stage => stage;
|
|
const stageDependencies = {};
|
|
const applyPpiOwnerSelectionToBatch = selection => { calls.selection = selection; };
|
|
const mergePpiReferenceSpeciesIntoBatch = () => {};
|
|
const clearPpiKaksReferencePause = stages => { calls.clear.push(stages); };
|
|
|
|
applyArabidopsisFallbackForPpiKaks(['ppi'], 'download failed');
|
|
console.log(JSON.stringify({calls, status: stageExecutionStatus.value.ppi}));
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"calls": {
|
|
"selection": {
|
|
"taxonId": "3702",
|
|
"storageKey": "At",
|
|
"selectionMode": "model_fallback",
|
|
"userConfirmed": True,
|
|
},
|
|
"clear": [],
|
|
},
|
|
"status": "paused",
|
|
}
|
|
|
|
|
|
def test_at_fallback_batch_owner_overrides_stale_spinach_stage_for_ppi_request() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const applyPpiOwnerFieldsToRequest =",
|
|
"const applyGeneFamilyContextToRequest =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const canonicalizePpiOwnerBiologicalScope = request => request;
|
|
const request = {};
|
|
applyPpiOwnerFieldsToRequest(request, 'ppi', {
|
|
ppiReferenceTaxonId: '3562',
|
|
ppiReferenceStorageKey: 'string_taxon_3562',
|
|
ppiReferenceSelectionMode: 'tree_nearest',
|
|
ppiReferenceUserConfirmed: true
|
|
}, {
|
|
ppiReferenceTaxonId: '3702',
|
|
ppiReferenceStorageKey: 'At',
|
|
ppiReferenceSelectionMode: 'model_fallback',
|
|
ppiReferenceUserConfirmed: true
|
|
}, {});
|
|
console.log(JSON.stringify(request));
|
|
"""
|
|
)
|
|
|
|
assert result["ppiReferenceTaxonId"] == "3702"
|
|
assert result["ppiReferenceStorageKey"] == "At"
|
|
assert result["ppiReferenceSelectionMode"] == "model_fallback"
|
|
assert result["ppiReferenceUserConfirmed"] is True
|
|
|
|
|
|
def test_normal_spinach_ppi_owner_request_is_unchanged() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const applyPpiOwnerFieldsToRequest =",
|
|
"const applyGeneFamilyContextToRequest =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const canonicalizePpiOwnerBiologicalScope = request => request;
|
|
const request = {};
|
|
const spinach = {
|
|
ppiReferenceTaxonId: '3562',
|
|
ppiReferenceStorageKey: 'string_taxon_3562',
|
|
ppiReferenceSelectionMode: 'tree_nearest',
|
|
ppiReferenceUserConfirmed: true
|
|
};
|
|
applyPpiOwnerFieldsToRequest(request, 'ppi', spinach, spinach, {});
|
|
console.log(JSON.stringify(request));
|
|
"""
|
|
)
|
|
|
|
assert result["ppiReferenceTaxonId"] == "3562"
|
|
assert result["ppiReferenceStorageKey"] == "string_taxon_3562"
|
|
assert result["ppiReferenceSelectionMode"] == "tree_nearest"
|
|
assert result["ppiReferenceUserConfirmed"] is True
|
|
|
|
|
|
def test_ppi_owned_ortholog_producer_is_single_flight_for_same_owner_and_reference_identity() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const executePpiOwnedOrthologProducer = async",
|
|
"const waitForPpiProducerDependency = async",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const ppiKaksInternalOrthologStatus = new Map();
|
|
const ppiKaksInternalOrthologPromises = new Map();
|
|
const ppiKaksInternalOrthologIdentityKey = producerStage => {
|
|
const producerParams = producerStage.params || {};
|
|
return [
|
|
String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase(),
|
|
String(producerParams.ppiReferenceTaxonId || '').trim(),
|
|
String(producerParams.ppiReferenceStorageKey || '').trim()
|
|
].join('::');
|
|
};
|
|
const getStageId = stage => stage.id;
|
|
const getStageLabel = stage => stage;
|
|
const addAgentMessage = () => {};
|
|
let executeCalls = 0;
|
|
const executeStageDirect = async () => {
|
|
executeCalls += 1;
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
};
|
|
const producer = {id: 'ortholog', params: {
|
|
dependencyOwner: 'ppi',
|
|
ppiReferenceTaxonId: '3562',
|
|
ppiReferenceStorageKey: 'string_taxon_3562'
|
|
}};
|
|
|
|
(async () => {
|
|
const results = await Promise.all([
|
|
executePpiOwnedOrthologProducer(producer),
|
|
executePpiOwnedOrthologProducer(producer)
|
|
]);
|
|
console.log(JSON.stringify({results, executeCalls}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"results": [True, True], "executeCalls": 1}
|
|
|
|
|
|
def test_ppi_owned_ortholog_producer_does_not_reuse_in_flight_promise_for_new_reference_identity() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const executePpiOwnedOrthologProducer = async",
|
|
"const waitForPpiProducerDependency = async",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const ppiKaksInternalOrthologStatus = new Map();
|
|
const ppiKaksInternalOrthologPromises = new Map();
|
|
const ppiKaksInternalOrthologIdentityKey = producerStage => {
|
|
const producerParams = producerStage.params || {};
|
|
return [
|
|
String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase(),
|
|
String(producerParams.ppiReferenceTaxonId || '').trim(),
|
|
String(producerParams.ppiReferenceStorageKey || '').trim()
|
|
].join('::');
|
|
};
|
|
const getStageId = stage => stage.id;
|
|
const getStageLabel = stage => stage;
|
|
const addAgentMessage = () => {};
|
|
const executeCalls = [];
|
|
const executeStageDirect = async (_stage, params) => {
|
|
executeCalls.push(params.ppiReferenceStorageKey);
|
|
if (params.ppiReferenceStorageKey === 'string_taxon_3562') {
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
throw new Error('old reference failed');
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
};
|
|
const oldProducer = {id: 'ortholog', params: {
|
|
dependencyOwner: 'ppi',
|
|
ppiReferenceTaxonId: '3562',
|
|
ppiReferenceStorageKey: 'string_taxon_3562'
|
|
}};
|
|
const atProducer = {id: 'ortholog', params: {
|
|
dependencyOwner: 'ppi',
|
|
ppiReferenceTaxonId: '3702',
|
|
ppiReferenceStorageKey: 'At'
|
|
}};
|
|
|
|
(async () => {
|
|
const results = await Promise.all([
|
|
executePpiOwnedOrthologProducer(oldProducer),
|
|
executePpiOwnedOrthologProducer(atProducer)
|
|
]);
|
|
console.log(JSON.stringify({results, executeCalls}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {
|
|
"results": [False, True],
|
|
"executeCalls": ["string_taxon_3562", "At"],
|
|
}
|
|
|
|
|
|
def test_resetting_ppi_producer_clears_completed_identity_for_same_reference_rerun() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
execute_helper = _source_block(
|
|
html,
|
|
"const executePpiOwnedOrthologProducer = async",
|
|
"const waitForPpiProducerDependency = async",
|
|
)
|
|
remember_helper = _source_block(
|
|
html,
|
|
"const ppiKaksInternalOrthologProducers = new Map();",
|
|
"const getPpiKaksInternalOrthologProducer =",
|
|
)
|
|
result = _run_node(
|
|
execute_helper
|
|
+ remember_helper
|
|
+ """
|
|
const ppiKaksInternalOrthologIdentityKey = producerStage => {
|
|
const producerParams = producerStage.params || {};
|
|
return [
|
|
String(producerParams.dependencyOwner || 'ppi').trim().toLowerCase(),
|
|
String(producerParams.ppiReferenceTaxonId || '').trim(),
|
|
String(producerParams.ppiReferenceStorageKey || '').trim()
|
|
].join('::');
|
|
};
|
|
const getStageId = stage => stage.id;
|
|
const getStageLabel = stage => stage;
|
|
const addAgentMessage = () => {};
|
|
let executeCalls = 0;
|
|
const executeStageDirect = async () => { executeCalls += 1; };
|
|
const atProducer = {id: 'ortholog', params: {
|
|
dependencyOwner: 'ppi',
|
|
ppiReferenceTaxonId: '3702',
|
|
ppiReferenceStorageKey: 'At'
|
|
}};
|
|
|
|
(async () => {
|
|
rememberPpiKaksInternalOrthologProducer('ppi', atProducer);
|
|
const first = await executePpiOwnedOrthologProducer(atProducer);
|
|
rememberPpiKaksInternalOrthologProducer('ppi', null);
|
|
rememberPpiKaksInternalOrthologProducer('ppi', atProducer);
|
|
const second = await executePpiOwnedOrthologProducer(atProducer);
|
|
console.log(JSON.stringify({first, second, executeCalls}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"first": True, "second": True, "executeCalls": 2}
|
|
|
|
|
|
def test_resume_does_not_change_running_ppi_back_to_pending() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const resumePpiKaksPausedStages = async",
|
|
"const ppiKaksFallbackRecoveryOwners =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const batchExecuteStages = {value: [{id: 'ppi'}]};
|
|
const stageExecutionStatus = {value: {ppi: 'running'}};
|
|
const isBatchExecuting = {value: true};
|
|
const shouldStopBatchExecute = {value: false};
|
|
const currentBatchStage = {value: ''};
|
|
const getStageId = stage => typeof stage === 'object' ? stage.id : stage;
|
|
const ppiKaksPauseStageIds = stages => stages;
|
|
const clearPpiKaksReferencePause = () => {};
|
|
const addAgentMessage = () => {};
|
|
const executeStagesWithDependencies = async () => {};
|
|
|
|
(async () => {
|
|
await resumePpiKaksPausedStages(['ppi'], 'At ready');
|
|
console.log(JSON.stringify({status: stageExecutionStatus.value.ppi}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"status": "running"}
|
|
|
|
|
|
def test_ppi_manifest_and_background_status_queries_use_canonical_owner_identity() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
manifest_block = _source_block(html, "const checkManifestExists = async", "const pollTaskUntilComplete = async")
|
|
background_block = _source_block(html, "const fetchStageCompleted = async", "const finalizeExecuteStageDirectRequest =")
|
|
expected = "canonicalizePpiOwnerBiologicalScope({ ...batchPlanParams, ...batchParams, ...stageParams })"
|
|
assert expected in manifest_block
|
|
assert expected in background_block
|
|
|
|
|
|
def test_ppi_preflight_retries_one_transient_network_error_before_returning_ready() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
retry_helper = _source_block(
|
|
html,
|
|
"const fetchWithTransientRetry = async",
|
|
"const pollReferenceBundleDownloadTask = async",
|
|
)
|
|
preflight = _source_block(
|
|
html,
|
|
"const runPpiSpeciesResolutionPreflight = async",
|
|
"const ppiKaksDependencyStages =",
|
|
)
|
|
result = _run_node(
|
|
retry_helper
|
|
+ preflight
|
|
+ """
|
|
const jobInfo = {job_id: 'job_network_retry'};
|
|
const executionPrefix = () => 'Ft.SPL';
|
|
const canonicalizePpiOwnerBiologicalScope = payload => payload;
|
|
const ppiNeedsNearReferenceDecision = () => false;
|
|
const formatPpiSpeciesResolutionBlockMessage = () => 'not ready';
|
|
const showStageConfirmDialog = async () => true;
|
|
const addAgentMessage = () => {};
|
|
const ElMessageBox = {alert: async () => {}};
|
|
const setTimeout = callback => callback();
|
|
let attempts = 0;
|
|
const fetch = async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) {
|
|
throw new TypeError('NetworkError when attempting to fetch resource.');
|
|
}
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({success: true, status: 'ready'})
|
|
};
|
|
};
|
|
|
|
(async () => {
|
|
const outcome = await runPpiSpeciesResolutionPreflight({
|
|
stage: 'ppi', prefix: 'Ft.SPL', targetSpecies: ['Ft']
|
|
}, {requestedStages: ['ppi']});
|
|
console.log(JSON.stringify({attempts, allowed: outcome.allowed}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"attempts": 2, "allowed": True}
|
|
|
|
|
|
def test_ppi_preflight_does_not_retry_a_valid_business_response() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
retry_helper = _source_block(
|
|
html,
|
|
"const fetchWithTransientRetry = async",
|
|
"const pollReferenceBundleDownloadTask = async",
|
|
)
|
|
preflight = _source_block(
|
|
html,
|
|
"const runPpiSpeciesResolutionPreflight = async",
|
|
"const ppiKaksDependencyStages =",
|
|
)
|
|
result = _run_node(
|
|
retry_helper
|
|
+ preflight
|
|
+ """
|
|
const jobInfo = {job_id: 'job_business_response'};
|
|
const executionPrefix = () => 'Ft.SPL';
|
|
const canonicalizePpiOwnerBiologicalScope = payload => payload;
|
|
const ppiNeedsNearReferenceDecision = () => false;
|
|
const formatPpiSpeciesResolutionBlockMessage = () => 'reference data missing';
|
|
const showStageConfirmDialog = async () => true;
|
|
const addAgentMessage = () => {};
|
|
const ElMessageBox = {alert: async () => {}};
|
|
const setTimeout = callback => callback();
|
|
let attempts = 0;
|
|
const fetch = async () => {
|
|
attempts += 1;
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
statusText: 'OK',
|
|
json: async () => ({success: true, status: 'needs_reference_data'})
|
|
};
|
|
};
|
|
|
|
(async () => {
|
|
const outcome = await runPpiSpeciesResolutionPreflight({
|
|
stage: 'ppi', prefix: 'Ft.SPL', targetSpecies: ['Ft']
|
|
}, {requestedStages: ['ppi']});
|
|
console.log(JSON.stringify({attempts, allowed: outcome.allowed}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"attempts": 1, "allowed": False}
|
|
|
|
|
|
def test_reference_bundle_status_poll_recovers_from_one_transient_network_error() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
helper = _source_block(
|
|
html,
|
|
"const fetchWithTransientRetry = async",
|
|
"const ppiKaksOwnedOrthologOwners =",
|
|
)
|
|
result = _run_node(
|
|
helper
|
|
+ """
|
|
const setTimeout = callback => callback();
|
|
const referenceBundleErrorMessage = (_data, fallback) => fallback;
|
|
const updateReferenceBundleDownloadProgress = () => {};
|
|
let attempts = 0;
|
|
const fetch = async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new TypeError('Failed to fetch');
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({status: 'success', result: {status: 'success', species_code: 'string_taxon_3562'}})
|
|
};
|
|
};
|
|
|
|
(async () => {
|
|
const result = await pollReferenceBundleDownloadTask('task-download');
|
|
console.log(JSON.stringify({attempts, status: result.status, species: result.species_code}));
|
|
})();
|
|
"""
|
|
)
|
|
|
|
assert result == {"attempts": 2, "status": "success", "species": "string_taxon_3562"}
|
|
|
|
|
|
def test_transient_retry_is_not_used_for_download_or_analysis_submission() -> None:
|
|
html = Path("apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
download_submit = _source_block(
|
|
html,
|
|
"const downloadRecommendedPpiReferenceBundle = async",
|
|
"const updateReferenceBundleDownloadProgress =",
|
|
)
|
|
execute_submit = _source_block(
|
|
html,
|
|
"const submitExecuteRequestWithAutoAnnotation = async",
|
|
"const waitForDialogTransition = async",
|
|
)
|
|
|
|
assert "fetch('/api/reference-bundle/download'" in download_submit
|
|
assert "fetchWithTransientRetry('/api/reference-bundle/download'" not in download_submit
|
|
assert "fetch('/api/execute'" in execute_submit
|
|
assert "fetchWithTransientRetry('/api/execute'" not in execute_submit
|