syntheogenesis / tests /test_identify.py
Tengo Gzirishvili
Fix: Identify/AlphaFold dead-ends on heavily-sequenced proteins (e.g. Cas9)
a635fbe
Raw
History Blame Contribute Delete
5.29 kB
"""Unit tests for the NCBI-BLAST identify flow and its Swiss-Prot structure
fallback.
The bug this guards against: ``nr`` is crowded with redundant GenBank / TPA
records (e.g. thousands of Cas9 orthologs as ``tpg|HER…``), none of which carry
a UniProt accession β€” so a heavily-sequenced protein came back from ``nr`` with
no UniProt hit and the AlphaFold embed dead-ended ("no model available") even
though one plainly exists. ``_attach_structure_homolog`` resolves the closest
reviewed (Swiss-Prot) protein, which always has an AlphaFold model.
Network is fully mocked β€” no live NCBI calls.
"""
import sys
import types
import pytest
from dee import server
# ── _extract_uniprot: only SwissProt / TrEMBL carry UniProt accessions ─────
class _Aln:
def __init__(self, hit_id, hit_def="X [Homo sapiens]", hsps=None, length=300):
self.hit_id = hit_id
self.hit_def = hit_def
self.hsps = hsps or []
self.length = length
self.accession = hit_id.split("|")[1] if "|" in hit_id else hit_id
class _Hsp:
def __init__(self, identities=250, align_length=300, expect=1e-40, bits=500):
self.identities = identities
self.align_length = align_length
self.expect = expect
self.bits = bits
def test_extract_uniprot_swissprot():
assert server._extract_uniprot(_Aln("sp|Q99ZW2|CAS9_STRP1")) == "Q99ZW2"
def test_extract_uniprot_trembl():
assert server._extract_uniprot(_Aln("tr|A0A1B2C3D4|A0A1B2C3D4_ECOLI")) == "A0A1B2C3D4"
def test_extract_uniprot_rejects_genbank_refseq_tpa():
# The exact shapes that buried the Cas9 UniProt entry in the bug report.
assert server._extract_uniprot(_Aln("tpg|HER4567195.1|")) is None
assert server._extract_uniprot(_Aln("ref|NP_001234.1|")) is None
assert server._extract_uniprot(_Aln("gb|AAA12345.1|")) is None
assert server._extract_uniprot(_Aln("pdb|1ABC|A")) is None
# ── _attach_structure_homolog: fallback only when nr gave no UniProt ───────
def _job(hits):
j = server.BlastJob(job_id="t", seq_hash="h")
j.hits = hits
return j
def test_fallback_pins_uniprot_onto_top_hit(monkeypatch):
# All nr hits look like the Cas9 case: no UniProt accession anywhere.
job = _job([
{"accession": "HER4567195.1", "uniprot": None, "organism": "Streptococcus pyogenes",
"description": "type II CRISPR RNA-guided endonuclease Cas9"},
{"accession": "HER4575708.1", "uniprot": None, "organism": "Streptococcus pyogenes",
"description": "type II CRISPR RNA-guided endonuclease Cas9"},
])
monkeypatch.setattr(server, "_swissprot_uniprot", lambda p: {
"uniprot": "Q99ZW2", "identity_pct": 99.8,
"organism": "Streptococcus pyogenes serotype M1", "description": "CRISPR-associated endonuclease Cas9/Csn1",
})
server._attach_structure_homolog(job, "MDKKYSIGLD" * 50)
top = job.hits[0]
assert top["uniprot"] == "Q99ZW2"
assert "AF-Q99ZW2-F1" in top["alphafold_url"]
assert top["alphafold_page"].endswith("/entry/Q99ZW2")
assert top["uniprot_via"] == "swissprot_homolog"
assert top["uniprot_identity_pct"] == 99.8
assert "serotype M1" in top["uniprot_organism"]
def test_fallback_skipped_when_uniprot_already_present(monkeypatch):
job = _job([{"accession": "X", "uniprot": "P12345"}])
called = {"n": 0}
monkeypatch.setattr(server, "_swissprot_uniprot",
lambda p: (called.__setitem__("n", called["n"] + 1), {"uniprot": "Q99ZW2"})[1])
server._attach_structure_homolog(job, "MMM")
assert called["n"] == 0 # never ran the fallback
assert job.hits[0]["uniprot"] == "P12345" # untouched
def test_fallback_noop_on_empty_hits(monkeypatch):
job = _job([])
monkeypatch.setattr(server, "_swissprot_uniprot", lambda p: {"uniprot": "Q99ZW2"})
server._attach_structure_homolog(job, "MMM") # must not raise
assert job.hits == []
def test_fallback_leaves_hits_clean_when_no_homolog(monkeypatch):
job = _job([{"accession": "X", "uniprot": None}])
monkeypatch.setattr(server, "_swissprot_uniprot", lambda p: None)
server._attach_structure_homolog(job, "MMM")
assert job.hits[0].get("uniprot") is None
assert "alphafold_url" not in job.hits[0]
# ── _swissprot_uniprot: parses a mocked Swiss-Prot BLAST result ────────────
def test_swissprot_uniprot_parses_mocked_blast(monkeypatch):
aln = _Aln("sp|Q99ZW2|CAS9_STRP1",
hit_def="CRISPR-associated endonuclease Cas9/Csn1 [Streptococcus pyogenes]",
hsps=[_Hsp(identities=1360, align_length=1368)], length=1368)
record = types.SimpleNamespace(alignments=[aln])
fake_ncbiww = types.SimpleNamespace(qblast=lambda **kw: "HANDLE")
fake_ncbixml = types.SimpleNamespace(parse=lambda handle: iter([record]))
fake_blast = types.ModuleType("Bio.Blast")
fake_blast.NCBIWWW = fake_ncbiww
fake_blast.NCBIXML = fake_ncbixml
monkeypatch.setitem(sys.modules, "Bio.Blast", fake_blast)
out = server._swissprot_uniprot("MDKKYSIGLD" * 50)
assert out["uniprot"] == "Q99ZW2"
assert out["identity_pct"] == pytest.approx(99.4, abs=0.2)
assert out["organism"] == "Streptococcus pyogenes"