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