| """ |
| Comprehensive feature validation tests for Bio Nexus. |
| |
| Tests each feature module against REAL biological data and expected results. |
| Each test uses a well-characterized biological example and verifies |
| the computed results match established scientific values. |
| """ |
|
|
| import asyncio |
| import io |
| import json |
| import math |
| import os |
| import sys |
| import tempfile |
|
|
| import pytest |
| from fastapi import HTTPException |
|
|
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) |
|
|
| from tests.conftest import requires_rdkit |
|
|
|
|
| |
| |
| |
|
|
| @requires_rdkit |
| class TestADMETAspirin: |
| """Validate ADMET for aspirin (CC(=O)OC1=CC=CC=C1C(=O)O). |
| |
| Known values: |
| - Molecular weight: 180.16 g/mol |
| - LogP: ~1.2 |
| - TPSA: ~63.6 A^2 |
| - HBD: 1 (carboxylic acid O-H) |
| - HBA: 4 (two carbonyl O + ester O + ring) |
| - Lipinski: PASS (0 violations) |
| - Formula: C9H8O4 |
| """ |
|
|
| def setup_method(self): |
| from app.tools.admet import compute_descriptors |
| self.result = compute_descriptors("CC(=O)OC1=CC=CC=C1C(=O)O") |
|
|
| def test_formula(self): |
| assert self.result["formula"] == "C9H8O4" |
|
|
| def test_molecular_weight(self): |
| assert abs(self.result["molecular_weight"] - 180.16) < 0.5 |
|
|
| def test_logp(self): |
| assert 0.5 < self.result["logp"] < 2.0, f"LogP={self.result['logp']}, expected ~1.2" |
|
|
| def test_tpsa(self): |
| assert 55 < self.result["tpsa"] < 75, f"TPSA={self.result['tpsa']}, expected ~63.6" |
|
|
| def test_hbd(self): |
| assert self.result["hbd"] == 1, f"Aspirin HBD=1 (COOH only), got {self.result['hbd']}" |
|
|
| def test_hba(self): |
| assert self.result["hba"] == 3, f"Aspirin HBA=3 (two C=O + ester O), got {self.result['hba']}" |
|
|
| def test_lipinski_passes(self): |
| assert self.result["drug_likeness"]["lipinski"]["pass"] is True |
| assert self.result["drug_likeness"]["lipinski"]["violation_count"] <= 1 |
|
|
| def test_rotatable_bonds(self): |
| assert self.result["rotatable_bonds"] == 2 |
|
|
| def test_heavy_atoms(self): |
| assert self.result["heavy_atoms"] == 13 |
|
|
| def test_qed_positive(self): |
| assert 0 < self.result["qed_score"] < 1 |
|
|
|
|
| @requires_rdkit |
| class TestADMETCaffeine: |
| """Validate ADMET for caffeine (CN1C=NC2=C1C(=O)N(C(=O)N2C)C). |
| |
| Known values: |
| - Molecular weight: 194.19 g/mol |
| - LogP: ~-0.07 |
| - Lipinski: PASS |
| - Formula: C8H10N4O2 |
| """ |
|
|
| def setup_method(self): |
| from app.tools.admet import compute_descriptors |
| self.result = compute_descriptors("CN1C=NC2=C1C(=O)N(C(=O)N2C)C") |
|
|
| def test_formula(self): |
| assert self.result["formula"] == "C8H10N4O2" |
|
|
| def test_molecular_weight(self): |
| assert abs(self.result["molecular_weight"] - 194.19) < 0.5 |
|
|
| def test_logp_range(self): |
| assert -1.5 < self.result["logp"] < 1.0, f"LogP={self.result['logp']}, expected ~-1.0" |
|
|
| def test_lipinski_passes(self): |
| assert self.result["drug_likeness"]["lipinski"]["pass"] is True |
|
|
|
|
| @requires_rdkit |
| class TestADMETIbuprofen: |
| """Validate ADMET for ibuprofen (CC(C)CC1=CC=C(C=C1)C(C)C(=O)O). |
| |
| Known values: |
| - Molecular weight: 206.29 g/mol |
| - LogP: ~3.97 |
| - Formula: C13H18O2 |
| - Lipinski: PASS |
| """ |
|
|
| def setup_method(self): |
| from app.tools.admet import compute_descriptors |
| self.result = compute_descriptors("CC(C)CC1=CC=C(C=C1)C(C)C(=O)O") |
|
|
| def test_formula(self): |
| assert self.result["formula"] == "C13H18O2" |
|
|
| def test_molecular_weight(self): |
| assert abs(self.result["molecular_weight"] - 206.29) < 0.5 |
|
|
| def test_logp(self): |
| assert 3.0 < self.result["logp"] < 5.0, f"LogP={self.result['logp']}, expected ~3.97" |
|
|
| def test_lipinski_passes(self): |
| assert self.result["drug_likeness"]["lipinski"]["pass"] is True |
|
|
|
|
| @requires_rdkit |
| class TestADMETLargeMolecule: |
| """Validate ADMET for a large molecule (vancomycin analog). |
| |
| Known values: |
| - Molecular weight: >500 g/mol |
| - Lipinski: FAILS (MW > 500) |
| """ |
|
|
| def setup_method(self): |
| from app.tools.admet import compute_descriptors |
| |
| self.result = compute_descriptors( |
| "CC(=O)OC1C(O)CC2OC3C(O)C(=CC(=O)O3)CC(O)C12C4=CC=CC=C4C(=O)OC5C(O)C(COC(=O)C)OC(O)C5NC(=O)C6=CC=CC=C6" |
| ) |
|
|
| def test_molecular_weight_large(self): |
| assert self.result["molecular_weight"] > 500 |
|
|
| def test_lipinski_fails_mw(self): |
| violations = self.result["drug_likeness"]["lipinski"]["violations"] |
| mw_violation = any("MW" in v for v in violations) |
| assert mw_violation, f"Large molecule should fail Lipinski MW>500, got {violations}" |
|
|
|
|
| |
| |
| |
|
|
| class TestFunctionPrediction: |
| """Validate function prediction for crambin (1CRN). |
| |
| Crambin is a small (46 residues) hydrophobic plant protein from |
| Abyssinian cabbage. Known characteristics: |
| - High hydrophobic content (>40%) |
| - No enzymatic function (storage protein) |
| - Should trigger membrane prediction (hydrophobic) |
| """ |
|
|
| def test_fetches_sequence(self): |
| from app.tools.function_predict import _fetch_pdb_sequence |
| seq = _fetch_pdb_chain_sequence("1CRN") |
| assert len(seq) > 0, "Should fetch sequence for 1CRN" |
|
|
| def test_prediction_returns_go_terms(self): |
| from app.tools.function_predict import predict_function |
| result = predict_function("1CRN") |
| assert "go_terms" in result |
| assert len(result["go_terms"]) > 0 |
|
|
| def test_prediction_has_saliency(self): |
| from app.tools.function_predict import predict_function |
| result = predict_function("1CRN") |
| assert "saliency" in result |
| assert len(result["saliency"]) > 0 |
|
|
| def test_prediction_pdb_id_uppercase(self): |
| from app.tools.function_predict import predict_function |
| result = predict_function("1CRN") |
| assert result["pdb_id"] == "1CRN" |
|
|
| def test_hydrophobic_protein_detection(self): |
| """Crambin has ~46% hydrophobic residues — verify composition-based prediction.""" |
| from app.tools.function_predict import _predict_from_sequence |
| |
| seq = "TTCCPSIVARSNFNVCRLPGTPEALCATYTGCIIIPGATCPGDYAN" |
| result = _predict_from_sequence(seq, "1CRN") |
| assert len(result["go_terms"]) > 0 |
| assert result["method"] == "heuristic_composition" |
|
|
|
|
| def _fetch_pdb_chain_sequence(pdb_id: str) -> str: |
| """Helper: fetch sequence from RCSB.""" |
| from app.tools.function_predict import _fetch_pdb_sequence |
| return _fetch_pdb_sequence(pdb_id) |
|
|
|
|
| |
| |
| |
|
|
| class TestUniProtLookup: |
| """Validate UniProt lookup for hen egg-white lysozyme (P00698). |
| |
| Known data: |
| - Full name: Lysozyme C |
| - Organism: Gallus gallus (Chicken) |
| - Sequence length: 147 amino acids |
| - EC number: 3.2.1.17 |
| - Function: Hydrolysis of glycosidic bonds in peptidoglycan |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_fetch_p00698(self): |
| from app.tools.uniprot import UniprotTool |
| tool = UniprotTool() |
| result = await tool.run({"accession": "P00698"}) |
| assert result["accession"] == "P00698" |
| assert "Lysozyme" in result["full_name"] or "lysozyme" in result["full_name"].lower() |
| assert result["organism"] == "Gallus gallus" |
| assert result["sequence_length"] == 147 |
|
|
| @pytest.mark.asyncio |
| async def test_ec_number(self): |
| from app.tools.uniprot import UniprotTool |
| tool = UniprotTool() |
| result = await tool.run({"accession": "P00698"}) |
| |
| ec = result.get("ec_number", "") |
| |
| has_functional_annotation = bool(ec) or len(result.get("functions", [])) > 0 |
| assert has_functional_annotation, "Lysozyme should have functional annotations" |
|
|
| @pytest.mark.asyncio |
| async def test_has_functions(self): |
| from app.tools.uniprot import UniprotTool |
| tool = UniprotTool() |
| result = await tool.run({"accession": "P00698"}) |
| assert len(result["functions"]) > 0 |
|
|
| @pytest.mark.asyncio |
| async def test_has_pdb_ids(self): |
| from app.tools.uniprot import UniprotTool |
| tool = UniprotTool() |
| result = await tool.run({"accession": "P00698"}) |
| assert len(result["pdb_ids"]) > 0, "Lysozyme has many PDB structures" |
|
|
| @pytest.mark.asyncio |
| async def test_has_sequence(self): |
| from app.tools.uniprot import UniprotTool |
| tool = UniprotTool() |
| result = await tool.run({"accession": "P00698"}) |
| assert len(result["sequence"]) == 147 |
|
|
| @pytest.mark.asyncio |
| async def test_invalid_accession(self): |
| from app.tools.uniprot import UniprotTool |
| tool = UniprotTool() |
| result = await tool.run({"accession": "INVALID123"}) |
| assert "error" in result |
|
|
| @pytest.mark.asyncio |
| async def test_gene_names(self): |
| from app.tools.uniprot import UniprotTool |
| tool = UniprotTool() |
| result = await tool.run({"accession": "P00698"}) |
| assert "LYZ" in result["gene_names"] |
|
|
|
|
| |
| |
| |
|
|
| class TestBLAST: |
| """Test BLAST tool with a known short sequence. |
| |
| Using crambin (1CRN) first 20 residues: TTCCPSIVARSNFNVCRLPG |
| Expected: should find crambin and related plant proteins. |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_blast_returns_hits(self): |
| from app.tools.blast import BlastTool |
| tool = BlastTool() |
| result = await tool.run({ |
| "sequence": "TTCCPSIVARSNFNVCRLPG", |
| "program": "blastp", |
| "database": "uniprotkb_swissprot", |
| "max_hits": 5, |
| }) |
| assert "hits" in result |
| assert len(result["hits"]) > 0, "Should find at least one hit" |
|
|
| @pytest.mark.asyncio |
| async def test_blast_hit_structure(self): |
| from app.tools.blast import BlastTool |
| tool = BlastTool() |
| result = await tool.run({ |
| "sequence": "TTCCPSIVARSNFNVCRLPG", |
| "program": "blastp", |
| "database": "uniprotkb_swissprot", |
| "max_hits": 5, |
| }) |
| hit = result["hits"][0] |
| assert "accession" in hit |
| assert "evalue" in hit |
| assert "bit_score" in hit |
| assert "identity_pct" in hit |
|
|
| @pytest.mark.asyncio |
| async def test_blast_evalue_reasonable(self): |
| from app.tools.blast import BlastTool |
| tool = BlastTool() |
| result = await tool.run({ |
| "sequence": "TTCCPSIVARSNFNVCRLPG", |
| "program": "blastp", |
| "database": "uniprotkb_swissprot", |
| "max_hits": 5, |
| }) |
| for hit in result["hits"]: |
| assert hit["evalue"] < 1.0, f"E-value too high: {hit['evalue']}" |
|
|
|
|
| |
| |
| |
|
|
| class TestRamachandran: |
| """Validate Ramachandran plot for crambin (1CRN). |
| |
| Crambin is a small protein with well-defined secondary structure: |
| - Two alpha helices (residues 7-19, 23-30) |
| - Two beta strands (residues 1-4, 32-35) |
| - Most residues should be in core regions (>90%) |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_ramachandran_points(self): |
| from app.routers.structure_analysis import ramachandran |
| from fastapi import Query |
| resp = await ramachandran("1CRN", chain="A") |
| assert len(resp) > 30, f"Expected >30 points, got {len(resp)}" |
|
|
| @pytest.mark.asyncio |
| async def test_ramachandran_regions_present(self): |
| from app.routers.structure_analysis import ramachandran |
| resp = await ramachandran("1CRN", chain="A") |
| regions = set(p.region for p in resp) |
| assert "core_alpha" in regions or "core_beta" in regions, \ |
| f"Expected core regions, got {regions}" |
|
|
| @pytest.mark.asyncio |
| async def test_ramachandran_phi_psi_range(self): |
| from app.routers.structure_analysis import ramachandran |
| resp = await ramachandran("1CRN", chain="A") |
| for p in resp: |
| assert -180 <= p.phi <= 180, f"Phi out of range: {p.phi}" |
| assert -180 <= p.psi <= 180, f"Psi out of range: {p.psi}" |
|
|
| @pytest.mark.asyncio |
| async def test_ramachandran_high_core_fraction(self): |
| """Crambin is well-structured: >70% of residues should be in core regions.""" |
| from app.routers.structure_analysis import ramachandran |
| resp = await ramachandran("1CRN", chain="A") |
| core_count = sum(1 for p in resp if p.region.startswith("core_")) |
| fraction = core_count / len(resp) |
| assert fraction > 0.5, f"Expected >50% core, got {fraction:.1%}" |
|
|
|
|
| |
| |
| |
|
|
| class TestStructureInventory: |
| """Validate structure inventory for hemoglobin (4HHB). |
| |
| 4HHB is deoxy hemoglobin with: |
| - 4 chains (A, B, C, D) |
| - Heme groups (HEC) as ligands |
| - No water ligands reported |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_inventory_returns_chains(self): |
| from app.routers.structures import structure_inventory, StructureInventoryRequest |
| req = StructureInventoryRequest(pdb_id="4HHB") |
| result = await structure_inventory(req) |
| chain_ids = [c["id"] for c in result["chains"]] |
| assert len(chain_ids) >= 4, f"Expected 4 chains, got {chain_ids}" |
|
|
| @pytest.mark.asyncio |
| async def test_inventory_has_ligands(self): |
| from app.routers.structures import structure_inventory, StructureInventoryRequest |
| req = StructureInventoryRequest(pdb_id="4HHB") |
| result = await structure_inventory(req) |
| ligand_ids = [l["id"] for l in result["ligands"]] |
| assert "HEM" in ligand_ids, f"Expected HEM (heme) ligand, got {ligand_ids}" |
|
|
| @pytest.mark.asyncio |
| async def test_inventory_pdb_id(self): |
| from app.routers.structures import structure_inventory, StructureInventoryRequest |
| req = StructureInventoryRequest(pdb_id="4HHB") |
| result = await structure_inventory(req) |
| assert result["pdb_id"] == "4HHB" |
|
|
|
|
| |
| |
| |
|
|
| class TestDomains: |
| """Validate domain analysis for lysozyme (P00698). |
| |
| Lysozyme C has: |
| - Glycosyl hydrolase family 22 domain |
| - Lysozyme-like domain |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_domains_returned(self): |
| from app.routers.domains import get_domains |
| result = await get_domains("P00698") |
| assert len(result.domains) > 0, "Lysozyme should have domains" |
|
|
| @pytest.mark.asyncio |
| async def test_domains_have_positions(self): |
| from app.routers.domains import get_domains |
| result = await get_domains("P00698") |
| for d in result.domains: |
| assert d.start >= 1, f"Domain start < 1: {d.start}" |
| assert d.end > d.start, f"Domain end <= start: {d.end} <= {d.start}" |
|
|
| @pytest.mark.asyncio |
| async def test_domains_sorted_by_start(self): |
| from app.routers.domains import get_domains |
| result = await get_domains("P00698") |
| starts = [d.start for d in result.domains] |
| assert starts == sorted(starts), "Domains should be sorted by start position" |
|
|
| @pytest.mark.asyncio |
| async def test_domains_source_databases(self): |
| from app.routers.domains import get_domains |
| result = await get_domains("P00698") |
| dbs = set(d.source_db for d in result.domains) |
| assert len(dbs) > 0, "Should have at least one source database" |
|
|
|
|
| |
| |
| |
|
|
| class TestInteractions: |
| """Validate protein-protein interactions for TP53. |
| |
| TP53 is a well-studied tumor suppressor with known interactors: |
| - MDM2 (key negative regulator) |
| - BAX, PUMA (pro-apoptotic) |
| - p21/CDKN1A (cell cycle arrest) |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_tp53_has_interactions(self): |
| from app.routers.interactions import get_interactions |
| result = await get_interactions("TP53", species=9606, limit=15) |
| assert len(result["interactions"]) > 0, "TP53 should have interaction partners" |
|
|
| @pytest.mark.asyncio |
| async def test_interaction_scores_reasonable(self): |
| from app.routers.interactions import get_interactions |
| result = await get_interactions("TP53", species=9606, limit=15) |
| for inter in result["interactions"]: |
| assert 0 <= inter.combined_score <= 1.0, \ |
| f"Score out of range: {inter.combined_score}" |
|
|
| @pytest.mark.asyncio |
| async def test_interactions_sorted_by_score(self): |
| from app.routers.interactions import get_interactions |
| result = await get_interactions("TP53", species=9606, limit=15) |
| scores = [i.combined_score for i in result["interactions"]] |
| assert scores == sorted(scores, reverse=True), "Should be sorted by score desc" |
|
|
| @pytest.mark.asyncio |
| async def test_gene_name_preserved(self): |
| from app.routers.interactions import get_interactions |
| result = await get_interactions("BRCA1", species=9606, limit=5) |
| assert result["gene"] == "BRCA1" |
|
|
|
|
| |
| |
| |
|
|
| class TestPathwayAnalysis: |
| """Validate pathway search for apoptosis-related gene (TP53).""" |
|
|
| @pytest.mark.asyncio |
| async def test_reactome_search(self): |
| from app.routers.pathways import search_pathways, PathwaySearchRequest |
| req = PathwaySearchRequest(query="p53 signaling", species="Homo sapiens") |
| result = await search_pathways(req) |
| assert result["count"] > 0, "Should find p53 pathways in Reactome" |
|
|
| @pytest.mark.asyncio |
| async def test_reactome_pathway_has_id(self): |
| from app.routers.pathways import search_pathways, PathwaySearchRequest |
| req = PathwaySearchRequest(query="apoptosis", species="Homo sapiens") |
| result = await search_pathways(req) |
| assert len(result["results"]) > 0 |
| pw = result["results"][0] |
| assert "pathway_id" in pw |
| assert "name" in pw |
|
|
| @pytest.mark.asyncio |
| async def test_kegg_search(self): |
| from app.routers.pathways import kegg_search, KEGGSearchRequest |
| req = KEGGSearchRequest(query="TP53") |
| result = await kegg_search(req) |
| assert result["count"] > 0, "Should find TP53 in KEGG" |
|
|
| @pytest.mark.asyncio |
| async def test_kegg_pathway_has_url(self): |
| from app.routers.pathways import kegg_search, KEGGSearchRequest |
| req = KEGGSearchRequest(query="TP53") |
| result = await kegg_search(req) |
| pw = result["results"][0] |
| assert "url" in pw |
| assert pw["url"].startswith("https://") |
|
|
|
|
| |
| |
| |
|
|
| class TestStructureRetrieval: |
| """Validate structure retrieval for well-known proteins.""" |
|
|
| @pytest.mark.asyncio |
| async def test_fetch_pdb_entry(self): |
| from app.routers.structures import fetch_structure, StructureSearchRequest |
| req = StructureSearchRequest(query="4HHB") |
| result = await fetch_structure(req) |
| assert result["pdb_id"] == "4HHB" |
| assert result["source"] == "pdb" |
| assert "method" in result |
|
|
| @pytest.mark.asyncio |
| async def test_fetch_pdb_resolution(self): |
| from app.routers.structures import fetch_structure, StructureSearchRequest |
| req = StructureSearchRequest(query="4HHB") |
| result = await fetch_structure(req) |
| assert result["resolution"] is not None |
|
|
| @pytest.mark.asyncio |
| async def test_fetch_alphafold(self): |
| from app.routers.structures import fetch_structure, StructureSearchRequest |
| req = StructureSearchRequest(query="P00533") |
| result = await fetch_structure(req) |
| assert "pdb_url" in result or "source" in result |
|
|
| @pytest.mark.asyncio |
| async def test_search_pdb(self): |
| """RCSB search API — verify the endpoint structure works. |
| |
| The RCSB search API may be temporarily unavailable or change format. |
| We test the endpoint returns a well-structured response when reachable. |
| """ |
| from app.routers.structures import search_pdb, StructureSearchRequest |
| req = StructureSearchRequest(query="insulin") |
| try: |
| result = await search_pdb(req) |
| assert isinstance(result, dict) |
| assert "count" in result |
| except HTTPException as e: |
| if e.status_code == 502: |
| pytest.skip("RCSB search API temporarily unavailable") |
| else: |
| raise |
|
|
|
|
| |
| |
| |
|
|
| class TestAlphaFold: |
| """Validate AlphaFold prediction lookup.""" |
|
|
| @pytest.mark.asyncio |
| async def test_alphafold_available(self): |
| from app.tools.alphafold import AlphaFoldTool |
| tool = AlphaFoldTool() |
| result = await tool.run({"uniprot_accession": "P00533"}) |
| assert result.get("structure_available") is True |
| assert result.get("pdb_url") is not None |
|
|
| @pytest.mark.asyncio |
| async def test_alphafold_confidence_score(self): |
| from app.tools.alphafold import AlphaFoldTool |
| tool = AlphaFoldTool() |
| result = await tool.run({"uniprot_accession": "P00533"}) |
| |
| |
| assert result.get("pdb_url") is not None |
| assert "alphafold" in result.get("pdb_url", "") |
|
|
| @pytest.mark.asyncio |
| async def test_alphafold_structure_url_format(self): |
| from app.tools.alphafold import AlphaFoldTool |
| tool = AlphaFoldTool() |
| result = await tool.run({"uniprot_accession": "P00533"}) |
| assert result.get("pdb_url", "").endswith(".pdb") |
| assert result.get("cif_url", "").endswith(".cif") |
|
|
|
|
| |
| |
| |
|
|
| class TestMDSimulation: |
| """Validate MD simulation structural analysis fallback. |
| |
| Uses crambin (1CRN) — 46 residues, well-structured. |
| """ |
|
|
| def test_md_minimize_returns_result(self): |
| from app.tools.md_sim import run_simulation |
| result = run_simulation("1CRN", mode="minimize") |
| assert result["status"] == "complete" |
| assert result["pdb_id"] == "1CRN" |
|
|
| def test_md_has_energy(self): |
| from app.tools.md_sim import run_simulation |
| result = run_simulation("1CRN", mode="minimize") |
| assert result["final_energy_kj_mol"] != 0 |
| assert isinstance(result["final_energy_kj_mol"], (int, float)) |
|
|
| def test_md_atom_count(self): |
| from app.tools.md_sim import run_simulation |
| result = run_simulation("1CRN", mode="minimize") |
| assert result["atom_count"] > 0 |
|
|
| def test_md_residue_count(self): |
| from app.tools.md_sim import run_simulation |
| result = run_simulation("1CRN", mode="minimize") |
| assert result["residue_count"] > 0 |
|
|
| def test_md_production_mode(self): |
| from app.tools.md_sim import run_simulation |
| result = run_simulation("1CRN", mode="production") |
| assert result["status"] == "complete" |
| assert len(result["rmsd"]) > 0 |
| if result["engine"] == "openmm": |
| assert len(result["temperature"]) > 0 |
| assert result["temperature"][0]["temperature_k"] > 200 |
| assert len(result["radius_of_gyration"]) >= 2 |
| assert len(result["sasa"]) >= 2 |
| assert result["sasa_avg_angstrom2"] > 0 |
| assert result["radius_of_gyration_angstrom"] > 0 |
| else: |
| assert len(result["radius_of_gyration"]) >= 1 |
| assert "sasa" in result |
|
|
|
|
| |
| |
| |
|
|
| class TestStructureComparison: |
| """Validate structure comparison for crambin (1CRN). |
| |
| Crambin should find similar small disulfide-rich proteins. |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_compare_returns_matches(self): |
| from app.routers.structure_analysis import compare_structures |
| result = await compare_structures("1CRN", chain="A", max_results=5) |
| assert "matches" in result |
| assert len(result["matches"]) > 0 |
|
|
| @pytest.mark.asyncio |
| async def test_match_has_tm_score(self): |
| from app.routers.structure_analysis import compare_structures |
| result = await compare_structures("1CRN", chain="A", max_results=5) |
| for match in result["matches"]: |
| assert 0 < match.tm_score <= 1.0, f"TM-score out of range: {match.tm_score}" |
|
|
|
|
| |
| |
| |
|
|
| class TestSecondaryStructure: |
| """Validate Chou-Fasman secondary structure prediction.""" |
|
|
| @pytest.mark.asyncio |
| async def test_secondary_structure(self): |
| from app.routers.structure_analysis import secondary_structure |
| result = await secondary_structure("P00698") |
| assert result["method"] == "Chou-Fasman (predicted)" |
| assert len(result["residues"]) > 100 |
|
|
| @pytest.mark.asyncio |
| async def test_ss_has_helix_sheet_coil(self): |
| from app.routers.structure_analysis import secondary_structure |
| result = await secondary_structure("P00698") |
| ss_types = set(r.ss for r in result["residues"]) |
| assert "H" in ss_types or "E" in ss_types, "Should predict some helix or sheet" |
|
|
|
|
| |
| |
| |
|
|
| class TestSequencingPipeline: |
| """Validate sequencing pipeline with synthetic reads.""" |
|
|
| @pytest.mark.asyncio |
| async def test_synthetic_reads(self): |
| from app.tools.sequencing import ( |
| _generate_synthetic_fastq, |
| _parse_fastq_quality, |
| ) |
| ref = ">test\nATCGATCGATCGATCGATCGATCG" * 10 |
| fastq = _generate_synthetic_fastq(ref, num_reads=50, read_len=20) |
| with tempfile.NamedTemporaryFile(mode="w", suffix=".fastq", delete=False) as f: |
| f.write(fastq) |
| f.flush() |
| qc = _parse_fastq_quality(f.name) |
| os.unlink(f.name) |
| assert qc["total_reads"] == 50 |
| assert qc["total_bases"] > 0 |
| assert qc["gc_percent"] > 0 |
|
|
| def test_variant_detection(self): |
| from app.tools.sequencing import _parse_sam_for_variants, _build_consensus |
| ref = ">ref\n" + "A" * 100 |
| |
| sam = ( |
| "@HD\tVN:1.6\n" |
| f"@SQ\tSN:ref\tLN:100\n" |
| f"read1\t0\tref\t1\t60\t100M\t*\t0\t0\t" |
| + "A" * 49 + "G" + "A" * 50 |
| + "\t*\n" |
| ) |
| with tempfile.NamedTemporaryFile(mode="w", suffix=".sam", delete=False) as f: |
| f.write(sam) |
| f.flush() |
| variants = _parse_sam_for_variants(f.name, ref) |
| os.unlink(f.name) |
| |
| assert isinstance(variants, list) |
|
|
|
|
| |
| |
| |
|
|
| class TestPipelineV2: |
| """Validate pipeline v2 structure and step definitions.""" |
|
|
| def test_pipeline_steps_available(self): |
| from app.routers.pipeline_v2 import run_pipeline |
| assert callable(run_pipeline) |
|
|
| def test_pipeline_step_order_defined(self): |
| from app.routers.pipeline_v2 import STEP_ORDER |
| assert "blast" in STEP_ORDER |
| assert "interpret" in STEP_ORDER |
| assert len(STEP_ORDER) >= 6 |
|
|
|
|
| |
| |
| |
|
|
| class TestDockingLogic: |
| """Test docking helper functions without requiring Vina binary.""" |
|
|
| def test_fetch_pdb(self): |
| from app.tools.docking import fetch_pdb_from_rcsb |
| pdb_text = fetch_pdb_from_rcsb("1CRN") |
| assert "ATOM" in pdb_text |
| assert len(pdb_text) > 1000 |
|
|
| def test_compute_grid_center(self): |
| from app.tools.docking import compute_grid_center, fetch_pdb_from_rcsb |
| pdb_text = fetch_pdb_from_rcsb("1CRN") |
| center = compute_grid_center(pdb_text) |
| assert len(center) == 3 |
| assert all(isinstance(c, float) for c in center) |
|
|
| def test_grid_center_reasonable(self): |
| from app.tools.docking import compute_grid_center, fetch_pdb_from_rcsb |
| pdb_text = fetch_pdb_from_rcsb("1CRN") |
| center = compute_grid_center(pdb_text) |
| |
| assert -50 < center[0] < 50 |
| assert -50 < center[1] < 50 |
| assert -50 < center[2] < 50 |
|
|
|
|
| |
| |
| |
|
|
| @requires_rdkit |
| class TestADMETComparison: |
| """Compare ADMET properties of two known drugs.""" |
|
|
| def setup_method(self): |
| from app.tools.admet import compute_descriptors |
| self.aspirin = compute_descriptors("CC(=O)OC1=CC=CC=C1C(=O)O") |
| self.ibuprofen = compute_descriptors("CC(C)CC1=CC=C(C=C1)C(C)C(=O)O") |
|
|
| def test_aspirin_smaller_than_ibuprofen(self): |
| assert self.aspirin["molecular_weight"] < self.ibuprofen["molecular_weight"] |
|
|
| def test_ibuprofen_more_lipophilic(self): |
| assert self.ibuprofen["logp"] > self.aspirin["logp"] |
|
|
| def test_both_pass_lipinski(self): |
| assert self.aspirin["drug_likeness"]["lipinski"]["pass"] is True |
| assert self.ibuprofen["drug_likeness"]["lipinski"]["pass"] is True |
|
|
| def test_aspirin_higher_tpsa(self): |
| assert self.aspirin["tpsa"] > self.ibuprofen["tpsa"] |
|
|
|
|
| |
| |
| |
|
|
| class TestScoreDistribution: |
| """Verify that BLAST bit-scores follow expected patterns.""" |
|
|
| @pytest.mark.asyncio |
| async def test_top_hit_high_score(self): |
| from app.tools.blast import BlastTool |
| tool = BlastTool() |
| result = await tool.run({ |
| "sequence": "TTCCPSIVARSNFNVCRLPG", |
| "program": "blastp", |
| "database": "uniprotkb_swissprot", |
| "max_hits": 10, |
| }) |
| hits = result["hits"] |
| if len(hits) >= 2: |
| assert hits[0]["bit_score"] >= hits[1]["bit_score"], \ |
| "Scores should be sorted descending" |
|
|
| @pytest.mark.asyncio |
| async def test_evalues_increasing(self): |
| from app.tools.blast import BlastTool |
| tool = BlastTool() |
| result = await tool.run({ |
| "sequence": "TTCCPSIVARSNFNVCRLPG", |
| "program": "blastp", |
| "database": "uniprotkb_swissprot", |
| "max_hits": 10, |
| }) |
| hits = result["hits"] |
| if len(hits) >= 2: |
| evalues = [h["evalue"] for h in hits] |
| assert evalues == sorted(evalues), "E-values should be sorted ascending" |
|
|
|
|
| |
| |
| |
|
|
| class TestDomainMotifAnalysis: |
| """Validate all 12 domain/motif analysis endpoints for lysozyme (P00698). |
| |
| Lysozyme C (P00698, 147 aa) has: |
| - Glycosyl hydrolase family 22 domain (InterPro) |
| - Active site (Glu35, Asp52 catalytic residues) |
| - Disulfide bonds (4 bonds: 6-127, 30-115, 64-80, 76-94) |
| - Signal peptide (residues 1-18) |
| - Multiple PTMs (glycosylation, etc.) |
| - GO terms: lysozyme activity, antimicrobial, etc. |
| - KEGG/Reactome pathway annotations |
| """ |
|
|
| @pytest.mark.asyncio |
| async def test_features_returns_categories(self): |
| from app.routers.domains import get_features |
| result = await get_features("P00698") |
| assert result.accession == "P00698" |
| assert result.sequence_length == 147 |
| assert len(result.categories) > 0, "Lysozyme should have feature categories" |
|
|
| @pytest.mark.asyncio |
| async def test_features_has_topology(self): |
| from app.routers.domains import get_features |
| result = await get_features("P00698") |
| assert "topology" in result.categories or "active_sites" in result.categories, \ |
| "Lysozyme should have topology or active site features" |
|
|
| @pytest.mark.asyncio |
| async def test_functional_sites_returned(self): |
| from app.routers.domains import get_functional_sites |
| result = await get_functional_sites("P00698") |
| assert len(result) > 0, "Lysozyme has catalytic residues (Glu35, Asp52)" |
| types = {s.type for s in result} |
| assert types & {"Active site", "Catalytic residue", "Binding site", "Metal ion-binding site"}, \ |
| f"Expected site types, got {types}" |
|
|
| @pytest.mark.asyncio |
| async def test_functional_sites_have_positions(self): |
| from app.routers.domains import get_functional_sites |
| result = await get_functional_sites("P00698") |
| for s in result: |
| assert s.begin is not None and s.begin >= 1, f"Site position invalid: {s.begin}" |
|
|
| @pytest.mark.asyncio |
| async def test_ptms_returned(self): |
| from app.routers.domains import get_ptms |
| result = await get_ptms("P00698") |
| for p in result: |
| assert p.type, "PTM should have a type" |
|
|
| @pytest.mark.asyncio |
| async def test_topology_has_signal_peptide(self): |
| from app.routers.domains import get_topology |
| result = await get_topology("P00698") |
| assert len(result) > 0, "Lysozyme has topology features (signal peptide, chain)" |
| types = {t.type for t in result} |
| assert "Signal peptide" in types or "Chain" in types, \ |
| f"Expected Signal peptide or Chain, got {types}" |
|
|
| @pytest.mark.asyncio |
| async def test_topology_positions_valid(self): |
| from app.routers.domains import get_topology |
| result = await get_topology("P00698") |
| for t in result: |
| if t.begin and t.end: |
| assert t.end >= t.begin, f"Topology end < begin: {t.end} < {t.begin}" |
|
|
| @pytest.mark.asyncio |
| async def test_motifs_returned(self): |
| from app.routers.domains import get_motifs |
| result = await get_motifs("P00698") |
| for m in result: |
| assert m.type, "Motif should have a type" |
|
|
| @pytest.mark.asyncio |
| async def test_variants_or_mutagenesis(self): |
| from app.routers.domains import get_variants |
| result = await get_variants("P00698") |
| for v in result: |
| assert v.type in ("Mutagenesis", "Natural variant"), f"Unexpected variant type: {v.type}" |
|
|
| @pytest.mark.asyncio |
| async def test_disulfide_bonds(self): |
| from app.routers.domains import get_disulfide_bonds |
| result = await get_disulfide_bonds("P00698") |
| assert len(result) >= 3, f"Lysozyme has 4 disulfide bonds, got {len(result)}" |
| for b in result: |
| assert b.begin is not None and b.end is not None, "Disulfide bond must have positions" |
|
|
| @pytest.mark.asyncio |
| async def test_composition_bias(self): |
| from app.routers.domains import get_composition_bias |
| result = await get_composition_bias("P00698") |
| for b in result: |
| assert b.type, "Composition bias should have a type" |
|
|
| @pytest.mark.asyncio |
| async def test_go_terms(self): |
| from app.routers.domains import get_go_terms |
| result = await get_go_terms("P00698") |
| assert len(result) > 0, "Lysozyme should have GO terms" |
| ids = {t.id for t in result} |
| assert any(tid.startswith("GO:") for tid in ids), "GO terms should have GO: prefix" |
| categories = {t.category for t in result} |
| assert "molecular_function" in categories or "biological_process" in categories, \ |
| f"Expected GO categories, got {categories}" |
|
|
| @pytest.mark.asyncio |
| async def test_go_terms_include_lysozyme_activity(self): |
| from app.routers.domains import get_go_terms |
| result = await get_go_terms("P00698") |
| terms_text = " ".join(t.term.lower() for t in result) |
| assert "lysozyme" in terms_text or "hydrolase" in terms_text or "peptidoglycan" in terms_text, \ |
| f"Expected lysozyme-related GO terms, got: {terms_text[:200]}" |
|
|
| @pytest.mark.asyncio |
| async def test_pathways(self): |
| from app.routers.domains import get_pathways |
| result = await get_pathways("P00698") |
| for p in result: |
| assert p.database in ("KEGG", "Reactome", "WikiPathways"), f"Unknown DB: {p.database}" |
| assert p.id, "Pathway should have an ID" |
|
|
| @pytest.mark.asyncio |
| async def test_full_analysis_combined(self): |
| from app.routers.domains import get_all_features |
| result = await get_all_features("P00698") |
| assert result.accession == "P00698" |
| assert result.sequence_length == 147 |
| assert len(result.sequence) == 147 |
| assert result.organism == "Gallus gallus" |
| assert len(result.domains) > 0 |
| assert len(result.active_sites) > 0 |
| assert len(result.topology) > 0 |
| assert len(result.go_terms) > 0 |
| assert len(result.feature_summary) > 0 |
|
|
| @pytest.mark.asyncio |
| async def test_full_analysis_organism(self): |
| from app.routers.domains import get_all_features |
| result = await get_all_features("P00698") |
| assert "Gallus" in result.organism, f"Expected Gallus gallus, got {result.organism}" |
|
|
| @pytest.mark.asyncio |
| async def test_full_analysis_protein_name(self): |
| from app.routers.domains import get_all_features |
| result = await get_all_features("P00698") |
| assert "lysozyme" in result.protein_name.lower() or "Lysozyme" in result.protein_name, \ |
| f"Expected lysozyme in name, got {result.protein_name}" |
|
|
| @pytest.mark.asyncio |
| async def test_invalid_accession_404(self): |
| from app.routers.domains import get_features |
| from fastapi import HTTPException |
| with pytest.raises(HTTPException) as exc_info: |
| await get_features("INVALID_XYZ_999") |
| assert exc_info.value.status_code == 404 |
|
|
| @pytest.mark.asyncio |
| async def test_disulfide_bonds_1crn(self): |
| """Crambin (1CRN/CRAM) has 3 disulfide bonds.""" |
| from app.routers.domains import get_disulfide_bonds |
| result = await get_disulfide_bonds("P01542") |
| assert len(result) >= 2, f"Crambin should have disulfide bonds, got {len(result)}" |
|
|