380 lines
18 KiB
Python
Executable File
380 lines
18 KiB
Python
Executable File
from pathlib import Path
|
|
|
|
from apps.web.vue3 import main as web_main
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _index_html() -> str:
|
|
return (REPO_ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_ppi_reset_dialog_opens_gene_list_without_instruction_textarea():
|
|
html = _index_html()
|
|
assert 'title="PPI Reset 输入基因列表"' in html
|
|
assert 'width="92%"' in html
|
|
assert 'class="ppi-reset-dialog-body"' in html
|
|
assert 'class="ppi-reset-table-wrap"' in html
|
|
assert 'max-height:70vh' in html
|
|
assert 'overflow:auto' in html
|
|
assert 'v-model="ppiResetSearch"' in html
|
|
assert 'class="ppi-reset-table"' in html
|
|
assert "v-for=\"row in ppiResetFilteredRows\"" in html
|
|
assert "{{ row.gene_id }}" in html
|
|
assert "{{ row.source }}" in html
|
|
assert "当前保留 {{ ppiResetKeptCount }} / {{ ppiResetRows.length }} 个 ID" in html
|
|
assert 'v-model="ppiResetInstruction"' not in html
|
|
|
|
|
|
def test_ppi_alphafold_links_remain_in_file_list_and_use_table_preview():
|
|
html = _index_html()
|
|
assert ".ppi_alphafold2_links.tsv')) return false" not in html
|
|
assert "AlphaFold DB/Fold2 单蛋白结构链接" not in html
|
|
assert "if (stage?.name === 'ppi') return !isImageFile(name);" in html
|
|
assert 'class="table-preview-table"' in html
|
|
assert "v-for=\"(row, rowIdx) in tablePreviewFilteredRows\"" in html
|
|
assert "当前显示 {{ tablePreviewFilteredRows.length }} / {{ tablePreviewRows.length }} 行" in html
|
|
assert "openTablePreview(file)" in html
|
|
assert "tablePreviewFilteredRows" in html
|
|
|
|
|
|
def test_ppi_result_action_opens_reset_dialog():
|
|
html = _index_html()
|
|
assert "isPpiResetIntent" in html
|
|
assert "raw.includes('基因id')" in html
|
|
assert '@click="openPpiResetDialog(stage)"' in html
|
|
assert 'title="PPI Reset 输入基因列表"' in html
|
|
|
|
|
|
def test_ppi_reset_uses_current_stage_prefix_before_default_execution_prefix():
|
|
html = _index_html()
|
|
assert "const inferPpiPrefixFromStage = (stage)" in html
|
|
assert "const ppiResetPrefix = ref('')" in html
|
|
assert "inferPpiPrefixFromStage(stage) || params.prefix || executionPrefix('ppi')" in html
|
|
assert "const prefixValue = ppiResetPrefix.value || params.prefix || executionPrefix('ppi')" in html
|
|
assert "prefix: prefixValue" in html
|
|
|
|
|
|
def test_ppi_species_resolution_api_contract_exists():
|
|
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
|
|
assert "class PpiSpeciesResolutionRequest(BaseModel)" in main_py
|
|
assert '@app.post("/api/ppi/species-resolution")' in main_py
|
|
assert "resolve_ppi_species_resolution(" in main_py
|
|
assert "targetSpecies: List[str]" in main_py
|
|
assert "referenceSpecies: List[str]" in main_py
|
|
assert "ignoreExistingOrtholog: bool = False" in main_py
|
|
assert "ignore_existing_ortholog=bool(req.ignoreExistingOrtholog)" in main_py
|
|
assert "requestedStages: List[str]" in main_py
|
|
assert "requested_stages=req.requestedStages or []" in main_py
|
|
assert '"reference_species": "referenceSpecies"' in main_py
|
|
assert '"requested_stages": "requestedStages"' in main_py
|
|
assert '"jobId": "job_id"' in main_py
|
|
|
|
|
|
def test_reference_bundle_download_api_contract_exists():
|
|
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
|
|
assert "class ReferenceBundleDownloadRequest(BaseModel)" in main_py
|
|
assert '@app.post("/api/reference-bundle/download")' in main_py
|
|
assert "download_reference_annotation_bundle(" in main_py
|
|
assert '"species_code": "speciesCode"' in main_py
|
|
assert '"taxon_id": "taxonId"' in main_py
|
|
assert '"requested_stages": "requestedStages"' in main_py
|
|
assert 'include_recommended=bool(payload.get("include_recommended"))' in main_py
|
|
|
|
|
|
def test_ppi_execute_path_runs_species_resolution_preflight_before_execute():
|
|
html = _index_html()
|
|
assert "const runPpiSpeciesResolutionPreflight = async" in html
|
|
assert "fetch('/api/ppi/species-resolution'" in html
|
|
assert "diagnostics.reference_recommendations" in html
|
|
assert "requestedStages" in html
|
|
assert "PPI/KaKs 前置数据检查" in html
|
|
assert "Spinacia oleracea" not in html
|
|
assert "const shouldRunPpiKaksPreflight =" in html
|
|
assert "const currentIsDependencyOwner = ppiKaksDependencyStages.has(currentStageId) || ppiKaksDependencyStages.has(requestBody.stage)" in html
|
|
assert "id: 'ppi_kaks_reference_status'" in html
|
|
assert "PPI/KaKs reference data and Ortholog mapping required" in html
|
|
|
|
|
|
def test_ppi_kaks_temporary_breaker_is_disabled_and_stage_failure_keeps_scheduler_alive():
|
|
html = _index_html()
|
|
assert "const ppiKaksTemporaryCircuitBreakerEnabled = false;" in html
|
|
assert "await ensureOrthologProducerForPpiKaks();" in html
|
|
assert "executeStageWithConfirmation(stage" in html
|
|
assert ".finally(() => scheduleCheck(0));" in html
|
|
|
|
catch_start = html.index(" } catch (err) {", html.index("const executeStageWithConfirmation = async"))
|
|
catch_end = html.index(" } finally {", catch_start)
|
|
ordinary_failure = html[catch_start:catch_end]
|
|
assert "stageExecutionStatus.value[stageId] = 'failed';" in ordinary_failure
|
|
assert "console.error('[ERROR] Stage failed:', stageId, err);" in ordinary_failure
|
|
|
|
scheduler_start = html.index("const executeStagesWithDependencies = async")
|
|
scheduler_end = html.index("const NCBI_SPECIES_DEFAULTS", scheduler_start)
|
|
scheduler = html[scheduler_start:scheduler_end]
|
|
assert "return deps.some(dep => stageExecutionStatus.value[dep] === 'failed');" in scheduler
|
|
assert "stageExecutionStatus.value[stageId] = 'failed';" in scheduler
|
|
|
|
|
|
def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
|
|
html = _index_html()
|
|
assert "'ortholog', 'phylogeny'" in html
|
|
assert "currentStageId === 'ortholog' && currentBatchIncludesPpiKaks" in html
|
|
assert "ignoreExistingOrtholog: currentStageId === 'ortholog'" in html
|
|
assert "allowNeedsOrtholog: currentStageId === 'ortholog'" in html
|
|
assert "ppiHasNearReferenceBeforeArabidopsis" in html
|
|
|
|
|
|
def test_ppi_is_preflighted_first_and_kaks_is_deferred_to_its_confirmation():
|
|
html = _index_html()
|
|
assert "const ppiKaksDependencyStages = new Set(['ppi', 'kaks'])" in html
|
|
assert "const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>" in html
|
|
assert "const ensureOrthologProducerForPpiKaks = async" in html
|
|
assert "for (const ownerStageId of requestedStages)" in html
|
|
assert "await ensurePpiKaksDependencyOwner(ownerStageId)" in html
|
|
assert "const requestedStages = [ownerStageId];" in html
|
|
assert "autoInsertedFor: requestedStages" in html
|
|
assert ".filter(stageId => stageId === 'ppi')" in html
|
|
assert "const prepareKaksSelectedReference = async" in html
|
|
assert "await ensurePpiKaksDependencyOwner('kaks', stageParams)" in html
|
|
assert "referencePolicySource: 'ppi_owner_ortholog_producer'" in html
|
|
assert "referencePolicySource: 'kaks_owner_ortholog_producer'" in html
|
|
assert "await ensureOrthologProducerForPpiKaks()" in html
|
|
|
|
|
|
def test_ppi_preflight_uses_current_stage_and_releases_direct_target_from_ortholog():
|
|
html = _index_html()
|
|
assert "const releaseDirectTargetPpiFromOrtholog = (resolution = {}) =>" in html
|
|
assert "resolution?.decision !== 'ready_target_string'" in html
|
|
assert "const requestedStages = currentIsDependencyOwner" in html
|
|
assert "? [currentStageId]" in html
|
|
assert "releaseDirectTargetPpiFromOrtholog(preflight.resolution);" in html
|
|
|
|
|
|
def test_ppi_needing_mapping_uses_hidden_internal_ortholog_producer():
|
|
html = _index_html()
|
|
start = html.index("const ensureOrthologStageForPpiKaks =")
|
|
end = html.index("const ensurePpiKaksDependencyOwner =", start)
|
|
producer = html[start:end]
|
|
|
|
assert "rememberPpiKaksInternalOrthologProducer('ppi'" in producer
|
|
assert "attachOrthologDependencyToOwners(requestedStages);" not in producer
|
|
assert "nextStages.splice" not in producer
|
|
assert "batchExecuteStages.value = nextStages" not in producer
|
|
|
|
|
|
def test_ppi_internal_ortholog_executes_without_visible_stage_scheduler():
|
|
html = _index_html()
|
|
start = html.index("const executePpiOwnedOrthologProducer =")
|
|
end = html.index("const waitForPpiProducerDependency =", start)
|
|
executor = html[start:end]
|
|
|
|
assert "executeStageDirect('ortholog', producerParams)" in executor
|
|
assert "executeStagesWithDependencies([producerStage]" not in executor
|
|
assert "stageExecutionStatus.value.ortholog" not in executor
|
|
|
|
|
|
def test_kaks_confirmation_chooses_ppi_or_project_reference_and_mapping_owner():
|
|
html = _index_html()
|
|
|
|
assert 'v-model="confirmParams.kaksReferenceSource"' in html
|
|
assert 'label="ppi"' in html
|
|
assert 'label="project"' in html
|
|
assert "orthologMappingOwner: usePpi ? 'ppi' : 'kaks'" in html
|
|
assert "requestBody.orthologMappingOwner = stageParams.orthologMappingOwner" in html
|
|
|
|
|
|
def test_ppi_and_kaks_static_dependencies_hide_internal_ortholog():
|
|
html = _index_html()
|
|
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
|
|
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
|
|
|
|
assert "'ppi': ['identify']," in html
|
|
assert '"ppi": ["identify"],' in catalog
|
|
assert '"ppi": ["identify"],' in main_py
|
|
assert "'kaks': ['identify']," in html
|
|
assert '"kaks": ["identify"],' in catalog
|
|
assert '"kaks": ["identify"],' in main_py
|
|
assert '"ortholog": ["identify"],' in catalog
|
|
|
|
|
|
def test_normal_full_gene_family_plan_excludes_ortholog_and_orders_ppi_before_kaks():
|
|
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
|
|
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
|
|
|
|
workflow_start = main_py.index("FULL_GENE_FAMILY_WORKFLOW_STAGES = [")
|
|
workflow_end = main_py.index("]", workflow_start)
|
|
workflow_block = main_py[workflow_start:workflow_end]
|
|
assert '"ortholog"' not in workflow_block
|
|
assert workflow_block.index('"ppi"') < workflow_block.index('"kaks"')
|
|
|
|
context_start = main_py.index("def _downstream_gene_family_stages_for_context")
|
|
context_end = main_py.index("def _resolve_contextual_gene_family_request", context_start)
|
|
context_block = main_py[context_start:context_end]
|
|
full_workflow_start = context_block.index("if _looks_like_gene_family_workflow_query(text):")
|
|
full_workflow_end = context_block.index("return stages", full_workflow_start)
|
|
full_workflow_block = context_block[full_workflow_start:full_workflow_end]
|
|
assert '"ortholog"' not in full_workflow_block
|
|
assert full_workflow_block.index('"ppi"') < full_workflow_block.index('"kaks"')
|
|
|
|
normalize_start = main_py.index("def _normalize_full_gene_family_workflow_plan")
|
|
normalize_end = main_py.index("def _", normalize_start + 4)
|
|
normalize_block = main_py[normalize_start:normalize_end]
|
|
assert 'if sid == "ortholog":' in normalize_block
|
|
|
|
|
|
def test_confirmed_full_workflow_recovers_original_user_request_from_history():
|
|
history = [
|
|
{"role": "user", "content": "执行基因家族分析"},
|
|
{"role": "assistant", "content": "是否继续?"},
|
|
{"role": "user", "content": "继续"},
|
|
]
|
|
|
|
assert web_main._full_gene_family_workflow_query_text("继续", history) == "执行基因家族分析"
|
|
assert web_main._full_gene_family_workflow_query_text("执行直系同源基因鉴定", []) == ""
|
|
|
|
|
|
def test_confirmed_full_workflow_removes_visible_ortholog_and_stale_kaks_dependency():
|
|
stages = [
|
|
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
|
|
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
|
|
]
|
|
details = [web_main._stage_detail_defaults(stage) for stage in stages]
|
|
next(item for item in details if item["id"] == "kaks")["dependencies"] = ["identify", "ortholog"]
|
|
|
|
normalized_stages, normalized_details = web_main._normalize_full_gene_family_workflow_plan(
|
|
message="执行基因家族分析",
|
|
stage_ids=stages,
|
|
stages_detail=details,
|
|
plan_params={"targetSpecies": ["Ft"], "referenceSpecies": ["At"], "geneFamily": "SPL"},
|
|
plan_prefix="Ft.SPL",
|
|
)
|
|
|
|
assert "ortholog" not in normalized_stages
|
|
assert normalized_stages.index("ppi") < normalized_stages.index("kaks")
|
|
detail_by_id = {item["id"]: item for item in normalized_details}
|
|
assert detail_by_id["ppi"]["dependencies"] == ["identify"]
|
|
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
|
|
assert detail_by_id["ppi"]["params"]["targetSpecies"] == ["Ft"]
|
|
assert detail_by_id["ppi"]["params"]["referenceSpecies"] == []
|
|
assert detail_by_id["ppi"]["params"]["scopeMode"] == "target_only"
|
|
assert detail_by_id["ppi"]["params"]["analysisRoute"] == "single_species"
|
|
assert detail_by_id["kaks"]["params"]["referenceSpecies"] == ["At"]
|
|
|
|
|
|
def test_deterministic_context_exit_applies_confirmed_full_workflow_contract():
|
|
request = web_main.AgentPlanRequest(
|
|
message="继续",
|
|
prefix="Ft.SPL",
|
|
history=[
|
|
{"role": "user", "content": "执行基因家族分析"},
|
|
{"role": "assistant", "content": "是否继续?"},
|
|
{"role": "user", "content": "继续"},
|
|
],
|
|
intent={"params": {"referenceSpecies": ["At"]}},
|
|
)
|
|
contextual_request = {
|
|
"family": "SPL",
|
|
"prefix": "Ft.SPL",
|
|
"stages": [
|
|
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
|
|
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
|
|
],
|
|
"targetSpecies": ["Ft"],
|
|
"referenceSpecies": ["At"],
|
|
"comparativeSpecies": [],
|
|
"scopeMode": "target_plus_reference",
|
|
"requiresConfirmedSubfamily": True,
|
|
"skipSubfamilyLabels": False,
|
|
"subfamilyState": {"by_species": {"Ft": {"status": "confirmed"}}},
|
|
"contextGate": {"status": "ready", "issues": []},
|
|
}
|
|
|
|
response = web_main._build_contextual_gene_family_plan_response(
|
|
request,
|
|
"test_confirmed_full_workflow",
|
|
contextual_request,
|
|
)
|
|
|
|
assert response["mode"] == "deterministic_context"
|
|
assert "ortholog" not in response["plan"]["stages"]
|
|
assert response["plan"]["stages"].index("ppi") < response["plan"]["stages"].index("kaks")
|
|
detail_by_id = {item["id"]: item for item in response["plan"]["stages_detail"]}
|
|
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
|
|
|
|
|
|
def test_kaks_request_carries_selected_ortholog_mapping_owner_to_backend():
|
|
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
|
|
|
|
assert 'orthologMappingOwner: str = ""' in main_py
|
|
assert '"orthologMappingOwner": getattr(req, \'orthologMappingOwner\', \'\')' in main_py
|
|
assert (
|
|
'ortholog_mapping_owner=params.get("orthologMappingOwner") '
|
|
'or params.get("ortholog_mapping_owner") or ""'
|
|
) in main_py
|
|
|
|
def test_ppi_plan_generation_never_inserts_static_ortholog_dependency():
|
|
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
|
|
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
|
|
|
|
assert '"ppi": ["identify", "ortholog"]' not in main_py
|
|
assert '"ppi": ["identify", "ortholog"]' not in catalog
|
|
|
|
context_start = main_py.index("def _downstream_gene_family_stages_for_context")
|
|
context_end = main_py.index("def _resolve_contextual_gene_family_request", context_start)
|
|
context = main_py[context_start:context_end]
|
|
ppi_start = context.index('if any(token in text for token in ["ppi", "互作"]):')
|
|
ppi_end = context.index('if any(token in text for token in ["表达", "expression", "rna"]):', ppi_start)
|
|
ppi_block = context[ppi_start:ppi_end]
|
|
assert 'stages.append("ppi")' in ppi_block
|
|
assert 'stages.append("ortholog")' not in ppi_block
|
|
assert 'elif stage_id in {"kaks", "ppi"} and "ortholog" in node_by_stage:' not in main_py
|
|
|
|
|
|
def test_ppi_preflight_recovered_reference_is_written_to_ppi_owner_only():
|
|
html = _index_html()
|
|
assert "const applyResolvedPpiReferenceToBatch = (resolution = {}, requestedStages = [], requestBody = {})" in html
|
|
assert "const applyPpiOwnerSelectionToBatch = (ownerSelection = {}, requestBody = {})" in html
|
|
assert "ppiReferenceTaxonId: taxonId" in html
|
|
assert "ppiReferenceStorageKey: storageKey" in html
|
|
assert "dependencyOwner: 'ppi'" in html
|
|
assert "requestBody.ppiKaksReferenceSpecies = [resolvedRef]" not in html
|
|
assert "const recoveredReferenceApplied = applyResolvedPpiReferenceToBatch(resolution, requestedStages, requestBody)" in html
|
|
|
|
|
|
def test_ready_preflight_propagates_recovered_reference_before_early_return():
|
|
html = _index_html()
|
|
start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>")
|
|
end = html.index("const ensureOrthologProducerForPpiKaks = async () =>", start)
|
|
producer = html[start:end]
|
|
propagation = "applyResolvedPpiReferenceToBatch(preflight.resolution, requestedStages, requestBody);"
|
|
ready_branch = "if (preflight.resolution?.status !== 'needs_ortholog')"
|
|
assert propagation in producer
|
|
assert producer.index(propagation) < producer.index(ready_branch)
|
|
|
|
|
|
def test_ppi_kaks_plan_marks_reference_as_preliminary():
|
|
html = _index_html()
|
|
assert "参考物种:${listText(refs)}(仅作为初步推荐,执行前将确定技术参考物种)" in html
|
|
|
|
|
|
def test_initial_ppi_kaks_reference_dialog_offers_project_reference():
|
|
html = _index_html()
|
|
assert 'label="project_reference"' in html
|
|
assert "confirmParams.ppiKaksProjectReferenceSpecies" in html
|
|
assert "decision === 'project_reference'" in html
|
|
assert "ppi_kaks_user_selected_project_reference" in html
|
|
|
|
|
|
def test_batch_confirmation_marks_both_ppi_kaks_reference_labels_as_preliminary():
|
|
html = _index_html()
|
|
start = html.index('v-if="!isBatchExecuting && batchExecuteParams && batchExecuteParams.geneFamilyScopeOptions"')
|
|
end = html.index('v-if="(batchExecuteStages || []).some', start)
|
|
batch_confirmation = html[start:end]
|
|
assert "const batchIncludesPpiKaks = () =>" in html
|
|
setup_return = html[html.index(" return {", html.index("onUnmounted(() =>")):]
|
|
assert "batchIncludesPpiKaks," in setup_return
|
|
assert "reference 参考物种{{ batchIncludesPpiKaks() ? '(仅作为初步推荐,执行前将确定技术参考物种)' : '' }}" in batch_confirmation
|
|
assert "batchIncludesPpiKaks() ? 'reference 参考物种(仅作为初步推荐,执行前将确定技术参考物种)' : 'reference 参考物种(仅用于标签迁移)'" in batch_confirmation
|