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

435 lines
18 KiB
Python

import json
from pathlib import Path
import subprocess
import pytest
from pydantic import ValidationError
from apps.web.vue3.main import ExecuteRequest
ROOT = Path(__file__).resolve().parents[2]
def test_wgs_qc_api_uses_exact_wgs_sample_config_field_name() -> None:
request = ExecuteRequest(stage="wgs_qc", wgsSampleConfig="input/wgs_samples.yaml")
assert request.wgsSampleConfig == "input/wgs_samples.yaml"
assert "wgsSampleConfig" in ExecuteRequest.model_fields
def test_confirmed_wgs_prefix_becomes_prefix_for_the_next_plan_request() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
start = text.index("const confirmedStageParamsForExecution =")
end = text.index("const shouldPauseForExpressionDataSource =", start)
helper = text[start:end]
script = f"""
const normalizedPrefix = value => String(value || '').trim();
const confirmParams = {{prefix: 'Os', cpu: 8}};
const params = {{prefix: 'At'}};
const batchExecuteParams = {{value: {{prefix: 'At', params: {{prefix: 'At'}}}}}};
const confirmCpuForStage = (_stage, value) => value;
const usesRnaseqSampleParallel = () => false;
const sampleParallelJobsForStage = (_stage, value) => value;
{helper}
const merged = confirmedStageParamsForExecution('wgs_qc', {{prefix: 'At'}});
console.log(JSON.stringify({{merged, params, batch: batchExecuteParams.value}}));
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
payload = json.loads(result.stdout)
assert payload["merged"]["prefix"] == "Os"
assert payload["params"]["prefix"] == "Os"
assert payload["batch"]["prefix"] == "Os"
assert payload["batch"]["params"]["prefix"] == "Os"
def test_wgs_index_frontend_uses_reference_and_tool_contract_fields() -> None:
request = ExecuteRequest(stage="wgs_index", wgsReferenceFasta="input/reference.fasta", wgsSamtools="samtools", wgsBwaMem2="bwa-mem2", wgsGatk="gatk")
assert request.wgsReferenceFasta == "input/reference.fasta"
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
assert "wgs_index" in text and "wgsReferenceFasta" in text
def test_frontend_binds_wgs_qc_config_and_fastp_parameters_to_execute_request() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
assert "applyWgsExecutionParams" in text
assert "wgsSampleConfig" in text
assert "wgsFastp" in text
assert "wgsMaxJobs" in text
assert text.count("applyWgsExecutionParams(requestBody, stageName, activeStageParams);") == 1
assert text.count("applyWgsExecutionParams(requestBody, stageId, stageParams);") == 1
active_call = text.index("applyWgsExecutionParams(requestBody, stageName, activeStageParams);")
batch_call = text.index("applyWgsExecutionParams(requestBody, stageId, stageParams);")
rnaseq_helper = text.index("const applyRnaseqExecutionParams")
assert active_call > rnaseq_helper and batch_call > rnaseq_helper
assert "applyWgsExecutionParams(requestBody, stageName, activeStageParams);" in text[active_call - 250:active_call + 100]
assert "applyWgsExecutionParams(requestBody, stageId, stageParams);" in text[batch_call - 250:batch_call + 100]
def test_wgs_execution_resource_params_fall_back_to_positive_integers() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
apply_start = text.index("const applyWgsExecutionParams =")
apply_end = text.index("const applyRnaseqExecutionParams =", apply_start)
script = f"""
const firstDefinedParam = (...values) => {{
for (const value of values) {{
if (value !== undefined && value !== null && value !== '') return value;
}}
return '';
}};
const numberParam = (value, defaultValue) => {{
const num = Number(value);
return Number.isFinite(num) ? num : defaultValue;
}};
const confirmParams = {{}};
{text[apply_start:apply_end]}
const empty = applyWgsExecutionParams({{}}, 'wgs_qc', {{}});
const invalid = applyWgsExecutionParams({{}}, 'wgs_qc', {{
wgsThreads: 0, wgsMaxJobs: -2
}});
const explicit = applyWgsExecutionParams({{}}, 'wgs_qc', {{
wgsThreads: 4, wgsMaxJobs: 2
}});
console.log(JSON.stringify({{empty, invalid, explicit}}));
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
payload = json.loads(result.stdout)
assert payload["empty"]["wgsThreads"] == 1
assert payload["empty"]["wgsMaxJobs"] == 1
assert payload["invalid"]["wgsThreads"] == 1
assert payload["invalid"]["wgsMaxJobs"] == 1
assert payload["explicit"]["wgsThreads"] == 4
assert payload["explicit"]["wgsMaxJobs"] == 2
def test_wgs_bam_qc_frontend_has_both_authorities_and_fixed_metric_name() -> None:
request = ExecuteRequest(
stage="wgs_bam_qc",
wgsMarkdupManifest="final/Cohort.wgs_markdup.manifest.json",
wgsIndexManifest="final/Cohort.wgs_index.manifest.json",
wgsIndexAnalysisId="wgs-analysis:" + "a" * 64,
wgsCoverageThreshold=1,
)
assert request.wgsMarkdupManifest.endswith(".wgs_markdup.manifest.json")
assert request.wgsCoverageThreshold == 1
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
for token in (
"wgs_bam_qc", "wgsMarkdupManifest", "wgsIndexManifest",
"wgsIndexAnalysisId", "wgsCoverageThreshold",
"wgsBamQcHandoffUpdates",
):
assert token in text
def test_wgs_bam_qc_coverage_threshold_is_fixed_to_one() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
apply_start = text.index("const applyWgsExecutionParams =")
apply_end = text.index("const applyRnaseqExecutionParams =", apply_start)
script = f"""
const firstDefinedParam = (...values) => {{
for (const value of values) {{
if (value !== undefined && value !== null && value !== '') return value;
}}
return '';
}};
const numberParam = (value, defaultValue) => {{
const num = Number(value);
return Number.isFinite(num) ? num : defaultValue;
}};
const confirmParams = {{}};
{text[apply_start:apply_end]}
const empty = applyWgsExecutionParams({{}}, 'wgs_bam_qc', {{}});
console.log(JSON.stringify(empty));
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
assert json.loads(result.stdout)["wgsCoverageThreshold"] == 1
with pytest.raises(ValidationError):
ExecuteRequest(stage="wgs_bam_qc", wgsCoverageThreshold=0)
def test_wgs_downstream_empty_numeric_params_keep_contract_defaults() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
apply_start = text.index("const applyWgsExecutionParams =")
apply_end = text.index("const applyRnaseqExecutionParams =", apply_start)
script = f"""
const firstDefinedParam = (...values) => {{
for (const value of values) {{
if (value !== undefined && value !== null && value !== '') return value;
}}
return '';
}};
const numberParam = (value, defaultValue) => {{
const num = Number(value);
return Number.isFinite(num) ? num : defaultValue;
}};
const booleanParam = (_value, defaultValue) => defaultValue;
const confirmParams = {{}};
{text[apply_start:apply_end]}
const haplotype = applyWgsExecutionParams(
{{}}, 'wgs_haplotype_calling', {{}}
);
const joint = applyWgsExecutionParams(
{{}}, 'wgs_joint_genotyping', {{}}
);
const impute = applyWgsExecutionParams({{}}, 'wgs_impute', {{}});
const finalFilter = applyWgsExecutionParams(
{{}}, 'wgs_maf_missing_filter', {{}}
);
console.log(JSON.stringify({{haplotype, joint, impute, finalFilter}}));
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
payload = json.loads(result.stdout)
assert payload["haplotype"]["wgsNativePairHmmThreads"] == 1
assert payload["joint"]["wgsMaxAlternateAlleles"] == 6
assert payload["joint"]["wgsCallingThreshold"] == 10
assert payload["impute"]["wgsWindow"] == 40
assert payload["impute"]["wgsOverlap"] == 2
assert payload["impute"]["wgsNe"] == 100000
assert payload["impute"]["wgsIterations"] == 12
assert payload["impute"]["wgsPhaseStates"] == 280
assert payload["impute"]["wgsImpStates"] == 1600
assert payload["impute"]["wgsCluster"] == 0.005
assert payload["finalFilter"]["wgsMaf"] == 0.05
assert payload["finalFilter"]["wgsMaxMissing"] == 0.5
@pytest.mark.parametrize(
("stage", "field"),
(
("wgs_haplotype_calling", "wgsNativePairHmmThreads"),
("wgs_joint_genotyping", "wgsMaxAlternateAlleles"),
("wgs_joint_genotyping", "wgsCallingThreshold"),
("wgs_impute", "wgsWindow"),
("wgs_impute", "wgsOverlap"),
("wgs_impute", "wgsNe"),
("wgs_impute", "wgsIterations"),
("wgs_impute", "wgsPhaseStates"),
("wgs_impute", "wgsImpStates"),
("wgs_impute", "wgsCluster"),
("wgs_maf_missing_filter", "wgsMaf"),
("wgs_maf_missing_filter", "wgsMaxMissing"),
),
)
def test_wgs_downstream_api_rejects_zero_scientific_params(
stage: str, field: str
) -> None:
with pytest.raises(ValidationError):
ExecuteRequest(stage=stage, **{field: 0})
def test_wgs_haplotype_frontend_uses_three_authorities_and_bam_qc_handoff() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
apply_start = text.index("const applyWgsExecutionParams =")
apply_end = text.index("const applyRnaseqExecutionParams =", apply_start)
handoff_start = text.index("const wgsHaplotypeCallingHandoffUpdates =")
handoff_end = text.index(
"const handoffWgsHaplotypeCallingProducerAuthority =", handoff_start
)
samples = "wgs-samples:" + "a" * 64
reference = "wgs-reference:" + "b" * 64
analysis = "wgs-analysis:" + "c" * 64
script = f"""
const firstDefinedParam = (...values) => values.find(v => v !== undefined && v !== null && v !== '');
const numberParam = (value, fallback) => Number(value || fallback);
const confirmParams = {{}};
{text[apply_start:apply_end]}
{text[handoff_start:handoff_end]}
const body = applyWgsExecutionParams({{}}, 'wgs_haplotype_calling', {{
wgsMarkdupManifest: 'markdup.json', wgsBamQcManifest: 'bam-qc.json',
wgsIndexManifest: 'index.json', wgsNativePairHmmThreads: 3
}});
const handoff = wgsHaplotypeCallingHandoffUpdates('wgs_bam_qc', {{
result: {{manifest_path: '/workspace/job/final/Cohort.wgs_bam_qc.manifest.json',
stage_status: {{completed: true, stage: 'wgs_bam_qc', prefix: 'Cohort',
wgs_sample_set_id: '{samples}', wgs_reference_id: '{reference}',
wgs_analysis_id: '{analysis}'}}}}
}}, 'Cohort');
console.log(JSON.stringify({{body, handoff}}));
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
payload = json.loads(result.stdout)
assert payload["body"]["wgsBamQcManifest"] == "bam-qc.json"
assert payload["body"]["wgsNativePairHmmThreads"] == 3
assert payload["handoff"]["wgsBamQcAnalysisId"] == analysis
def test_wgs_gvcf_combine_frontend_uses_two_authorities_and_handoff() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
apply_start = text.index("const applyWgsExecutionParams =")
apply_end = text.index("const applyRnaseqExecutionParams =", apply_start)
handoff_start = text.index("const wgsGvcfCombineHandoffUpdates =")
handoff_end = text.index(
"const handoffWgsGvcfCombineProducerAuthority =", handoff_start
)
samples = "wgs-samples:" + "a" * 64
reference = "wgs-reference:" + "b" * 64
analysis = "wgs-analysis:" + "c" * 64
script = f"""
const firstDefinedParam = (...values) => values.find(v => v !== undefined && v !== null && v !== '');
const numberParam = (value, fallback) => Number(value || fallback);
const confirmParams = {{}};
{text[apply_start:apply_end]}
{text[handoff_start:handoff_end]}
const body = applyWgsExecutionParams({{}}, 'wgs_gvcf_combine', {{
wgsHaplotypeManifest: 'haplotype.json', wgsIndexManifest: 'index.json',
wgsGatk: '/opt/gatk'
}});
const handoff = wgsGvcfCombineHandoffUpdates('wgs_haplotype_calling', {{
result: {{manifest_path: '/workspace/job/final/Cohort.wgs_haplotype_calling.manifest.json',
stage_status: {{completed: true, stage: 'wgs_haplotype_calling', prefix: 'Cohort',
wgs_sample_set_id: '{samples}', wgs_reference_id: '{reference}',
wgs_analysis_id: '{analysis}'}}}}
}}, 'Cohort');
console.log(JSON.stringify({{body, handoff}}));
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
payload = json.loads(result.stdout)
assert payload["body"]["wgsHaplotypeManifest"] == "haplotype.json"
assert payload["body"]["wgsIndexManifest"] == "index.json"
assert payload["body"]["wgsGatk"] == "/opt/gatk"
assert payload["handoff"]["wgsHaplotypeAnalysisId"] == analysis
def test_wgs_joint_genotyping_frontend_uses_two_authorities_parameters_and_handoff() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
apply_start = text.index("const applyWgsExecutionParams =")
apply_end = text.index("const applyRnaseqExecutionParams =", apply_start)
handoff_start = text.index("const wgsJointGenotypingHandoffUpdates =")
handoff_end = text.index(
"const handoffWgsJointGenotypingProducerAuthority =", handoff_start
)
samples = "wgs-samples:" + "a" * 64
reference = "wgs-reference:" + "b" * 64
analysis = "wgs-analysis:" + "c" * 64
script = f"""
const firstDefinedParam = (...values) => values.find(v => v !== undefined && v !== null && v !== '');
const numberParam = (value, fallback) => Number(value || fallback);
const booleanParam = (value, fallback) => value === undefined || value === null || value === '' ? fallback : Boolean(value);
const confirmParams = {{}};
{text[apply_start:apply_end]}
{text[handoff_start:handoff_end]}
const body = applyWgsExecutionParams({{}}, 'wgs_joint_genotyping', {{
wgsCombineManifest: 'combine.json', wgsIndexManifest: 'index.json',
wgsGatk: '/opt/gatk', wgsMaxAlternateAlleles: 6,
wgsCallingThreshold: 10, wgsIncludeNonVariantSites: false
}});
const handoff = wgsJointGenotypingHandoffUpdates('wgs_gvcf_combine', {{
result: {{manifest_path: '/workspace/job/final/Cohort.wgs_gvcf_combine.manifest.json',
stage_status: {{completed: true, stage: 'wgs_gvcf_combine', prefix: 'Cohort',
wgs_sample_set_id: '{samples}', wgs_reference_id: '{reference}',
wgs_analysis_id: '{analysis}'}}}}
}}, 'Cohort');
console.log(JSON.stringify({{body, handoff}}));
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
payload = json.loads(result.stdout)
assert payload["body"]["wgsCombineManifest"] == "combine.json"
assert payload["body"]["wgsIndexManifest"] == "index.json"
assert payload["body"]["wgsMaxAlternateAlleles"] == 6
assert payload["body"]["wgsCallingThreshold"] == 10
assert payload["body"]["wgsIncludeNonVariantSites"] is False
assert payload["handoff"]["wgsCombineAnalysisId"] == analysis
def test_wgs_variant_select_frontend_has_confirmed_fallback_contract() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
for token in (
"wgs_variant_select",
"wgsJointManifest",
"wgsMethod",
"wgsBcftools",
"wgsJointAnalysisId",
"confirmWgsFallback",
"cancelWgsFallback",
):
assert token in text
assert "wgsMethod: pendingWgsFallback.fallback_method" in text
def test_wgs_variant_select_modal_uses_real_reason_cancel_zero_confirm_once() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
start = text.index("const wgsFallbackDetails =")
end = text.index("const waitForDialogTransition =", start)
script = f"""
{text[start:end]}
let requests = [];
let shown = [];
const error = {{responseData: {{result: {{
error: 'GATK SelectVariants SNP failed with exit code 23',
fallback_method: 'bcftools'
}}}}}};
const executeFn = async (stage, params) => {{
requests.push({{stage, params}});
return {{ok: true}};
}};
(async () => {{
await confirmWgsFallback(error, 'wgs_variant_select', {{wgsMethod: 'gatk'}},
executeFn, async (message) => {{ shown.push(message); throw new Error('cancel'); }});
const afterCancel = requests.length;
await confirmWgsFallback(error, 'wgs_variant_select', {{wgsMethod: 'gatk'}},
executeFn, async (message) => {{ shown.push(message); }});
console.log(JSON.stringify({{afterCancel, requests, shown}}));
}})();
"""
result = subprocess.run(
["/usr/bin/node", "-e", script],
text=True,
capture_output=True,
check=True,
)
payload = json.loads(result.stdout)
assert payload["afterCancel"] == 0
assert len(payload["requests"]) == 1
assert payload["requests"][0]["stage"] == "wgs_variant_select"
assert payload["requests"][0]["params"]["wgsMethod"] == "bcftools"
assert all(
"GATK SelectVariants SNP failed with exit code 23" in message
for message in payload["shown"]
)
def test_active_foreground_batch_guards_wgs_out_of_background_executor() -> None:
text = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
start = text.index("const plannedExecutionStages =")
end = text.index("// 存储批量执行信息", start)
scheduling_branch = text[start:end]
background_call = scheduling_branch.index("startBackgroundBatchExecution(")
assert "wgs_" in scheduling_branch[:background_call].lower()
assert "startBackgroundBatchExecution(" in scheduling_branch