Spaces:
Running
Running
File size: 12,191 Bytes
5d5e978 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | """
Primer design QC + in-silico PCR validation.
Validates the oligo QC helpers and the /api/primers/* endpoints against
REAL biological reference data:
* Human TP53 CDS (NM_000546.6, 393 aa / 1182 bp) — a canonical reference
used for p53 mutation screening in the literature.
* Known GC% / Tm arithmetic checks.
* Known hairpin- and dimer-prone sequences.
The end-to-end test runs Primer3 on the TP53 CDS and verifies, for EVERY
returned primer pair, that the primers actually bind the reference template at
the reported coordinates and that the in-silico PCR amplicon matches the
reported product size.
"""
import os
import sys
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from app.tools.oligo_qc import (
reverse_complement,
gc_content,
salt_adjusted_tm,
hairpin_analysis,
dimer_analysis,
in_silico_pcr,
find_binding_sites,
clean,
)
# Human TP53 CDS — NM_000546.6 (verified via NCBI efetch, 2026-08-07)
TP53_CDS = (
"ATGGAGGAGCCGCAGTCAGATCCTAGCGTCGAGCCCCCTCTGAGTCAGGAAACATTTTCAGACCTATGGAAACTACTTCCTGAAAACAACGTTCTGTC"
"CCCCTTGCCGTCCCAAGCAATGGATGATTTGATGCTGTCCCCGGACGATATTGAACAATGGTTCACTGAAGACCCAGGTCCAGATGAAGCTCCCAGAA"
"TGCCAGAGGCTGCTCCCCCCGTGGCCCCTGCACCAGCAGCTCCTACACCGGCGGCCCCTGCACCAGCCCCCTCCTGGCCCCTGTCATCTTCTGTCCCT"
"TCCCAGAAAACCTACCAGGGCAGCTACGGTTTCCGTCTGGGCTTCTTGCATTCTGGGACAGCCAAGTCTGTGACTTGCACGTACTCCCCTGCCCTCAAC"
"AAGATGTTTTGCCAACTGGCCAAGACCTGCCCTGTGCAGCTGTGGGTTGATTCCACACCCCCGCCCGGCACCCGCGTCCGCGCCATGGCCATCTACAA"
"GCAGTCACAGCACATGACGGAGGTTGTGAGGCGCTGCCCCCACCATGAGCGCTGCTCAGATAGCGATGGTCTGGCCCCTCCTCAGCATCTTATCCGAG"
"TGGAAGGAAATTTGCGTGTGGAGTATTTGGATGACAGAAACACTTTTCGACATAGTGTGGTGGTGCCCTATGAGCCGCCTGAGGTTGGCTCTGACTGT"
"ACCACCATCCACTACAACTACATGTGTAACAGTTCCTGCATGGGCGGCATGAACCGGAGGCCCATCCTCACCATCATCACACTGGAAGACTCCAGTGG"
"TAATCTACTGGGACGGAACAGCTTTGAGGTGCGTGTTTGTGCCTGTCCTGGGAGAGACCGGCGCACAGAGGAAGAGAATCTCCGCAAGAAAGGGGAGC"
"CTCACCACGAGCTGCCCCCAGGGAGCACTAAGCGAGCACTGCCCAACAACACCAGCTCCTCTCCCCAGCCAAAGAAGAAACCACTGGATGGAGAATAT"
"TTCACCCTTCAGATCCGTGGGCGTGAGCGCTTCGAGATGTTCCGAGAGCTGAATGAGGCCTTGGAACTCAAGGATGCCCAGGCTGGGAAGGAGCCAGG"
"GGGGAGCAGGGCTCACTCCAGCCACCTGAAGTCCAAAAAGGGTCAGTCTACCTCCCGCCATAAAAAACTCATGTTCAAGACAGAAGGGCCTGACTCAG"
"ACTGA"
)
# ============================================================================
# 1. Core sequence math
# ============================================================================
class TestQueryBuilder:
def test_plain_gene_translated_to_field_query(self):
from app.routers.primers import _build_nucleotide_query
assert _build_nucleotide_query("TP53 human") == "TP53[Gene Name] AND Homo sapiens[Organism]"
assert _build_nucleotide_query("BRCA1") == "BRCA1[Gene Name]"
assert _build_nucleotide_query("brca1 mouse") == "brca1[Gene Name] AND Mus musculus[Organism]"
def test_existing_ncbi_query_passed_through(self):
from app.routers.primers import _build_nucleotide_query
assert _build_nucleotide_query("TP53[Gene Name] AND Homo sapiens[Organism]") == (
"TP53[Gene Name] AND Homo sapiens[Organism]"
)
assert _build_nucleotide_query("") == ""
def test_accession_query_uses_accn_field(self):
from app.routers.primers import _build_nucleotide_query
assert _build_nucleotide_query("NM_000546.6") == "NM_000546.6[ACCN]"
assert _build_nucleotide_query("NG_017013") == "NG_017013[ACCN]"
class TestCoreMath:
def test_reverse_complement(self):
assert reverse_complement("ATGC") == "GCAT"
assert reverse_complement("AATTCCGG") == "CCGGAATT"
assert reverse_complement("ATGCN") == "NGCAT"
# double application returns the original
assert reverse_complement(reverse_complement("ATGCGTCAGTNN")) == "ATGCGTCAGTNN"
def test_gc_content_known(self):
# G=3, C=2 of 10 -> 50%
assert gc_content("ATGCGTCAGT") == 50.0
# pure GC -> 100%, pure AT -> 0%
assert gc_content("GGGGCCCC") == 100.0
assert gc_content("AAAAAATTTTTT") == 0.0
def test_tm_sane_range_for_20mer(self):
# Literature 20-mer primers generally sit at 45-65C; very GC-poor
# oligos (25% GC) can legitimately dip to ~44C.
for seq in ("ATGCGTCAGTATGACCTGTC", "CGGGTAGCTAGCATGCTAGG",
"GGGGCCCCAAAATTTTCCCC", "TAGTAGTAGTAGTAGTAGTA"):
tm = salt_adjusted_tm(seq)
assert 40.0 <= tm <= 75.0, f"Tm {tm} out of range for {seq}"
# higher GC => higher Tm for equal length
low_gc = salt_adjusted_tm("AAAAATTTTTAAAAATTTTT")
high_gc = salt_adjusted_tm("GGGGGCCCCCGGGGGCCCCC")
assert high_gc > low_gc
def test_clean_strips_junk(self):
assert clean(" atg-c\n gtc ") == "ATGCGTC"
assert clean("Atg cgtAGt") == "ATGCGTAGT"
# ============================================================================
# 2. Hairpin / dimer detection on known sequences
# ============================================================================
class TestStructures:
def test_hairpin_detected(self):
# CCCC stem pairs with GGGG, loop TTT between -> hairpin.
report = hairpin_analysis("AAAACCCCTTTGGGG")
assert report["stem_length"] >= 4
assert report["dg"] < 0
assert report["risk"] in ("high", "medium", "low")
def test_no_hairpin_on_plain_seq(self):
# A 20-mer empirically verified to contain no inverted repeat of
# >= 4 bases (so it cannot fold a stem-loop).
report = hairpin_analysis("GCTAAAGACAATTACATAAC")
assert report["risk"] == "none"
def test_self_dimer_palindrome_is_high_risk(self):
# GGGGGCCCCC is a palindrome; two copies dimerize fully.
report = dimer_analysis("GGGGGCCCCC", "GGGGGCCCCC")
assert report["dg"] <= -10
assert report["risk"] == "high"
assert report["involves_a3"]
assert report["involves_b3"]
def test_self_dimer_random_oligo_is_low_risk(self):
# A random 20-mer empirically verified to contain no self-complementary
# run of >= 4 canonical base pairs.
report = dimer_analysis("TGGCATTTTTATTACACTCA", "TGGCATTTTTATTACACTCA")
assert report["risk"] in ("none", "low")
def test_hetero_dimer_complementary_pair(self):
# A primer and its reverse complement anneal perfectly.
fwd = "GTACCTAGCTAAGCT"
rev = reverse_complement(fwd)
report = dimer_analysis(fwd, rev)
assert report["dg"] <= -10
assert report["risk"] == "high"
# ============================================================================
# 3. In-silico PCR against the TP53 reference
# ============================================================================
class TestInsilicoPCR:
def test_synthetic_amplicon_coordinates(self):
template = ("A" * 10) + "CCGTAGCTAGCTAGCGTACCG" + ("A" * 40) + "CGTACGTACGTAGGCTAACCCG" + ("A" * 30)
# left binds at index 10..31, right target region 52..72
left = template[10:31]
right = reverse_complement(template[52:72])
pcr = in_silico_pcr(template, left, right,
expected_product=(72 - 10), left_expected=10, right_expected=71)
assert 10 in pcr["forward_positions"]
assert 52 in pcr["reverse_positions"]
assert pcr["specific"]
assert pcr["primer3_consistent"]
assert pcr["matches_product_size"] is True
def test_primers_bind_reference_at_expected_spots(self):
# Reference primers spanning the entire TP53 CDS (NM_000546.6):
# forward = 5'-ATGGAGGAGCCGCAGTCAGA-3' -> CDS start (index 0)
# reverse = 5'-TCAGTCTGAGTCAGGCCCTT-3' -> reverse complement of
# the CDS end (anneals at index 1162, 20 bases 1162..1181)
fwd = "ATGGAGGAGCCGCAGTCAGA"
rev = "TCAGTCTGAGTCAGGCCCTT"
assert find_binding_sites(TP53_CDS, fwd) == [0], "forward primer must bind TP53 CDS start"
assert find_binding_sites(TP53_CDS, reverse_complement(rev)) == [1162], (
"reverse primer must bind TP53 CDS end"
)
def test_nonbinding_primer_flagged(self):
pcr = in_silico_pcr(TP53_CDS, "TTTTTTTTTTTTTTTTTT", "AAAAAAAAAAAAAAAAAAAA",
expected_product=200, left_expected=0, right_expected=199)
assert pcr["specific"] is False
assert pcr["matches_product_size"] is False
assert pcr["note"]
# ============================================================================
# 4. End-to-end Primer3 design on TP53 CDS — verify every returned pair
# ============================================================================
@pytest.fixture(scope="module")
def primer_client():
app = FastAPI(title="Primer Tests")
from app.routers import primers
app.include_router(primers.router)
with TestClient(app, raise_server_exceptions=False) as c:
yield c
def test_design_primers_on_tp53_and_verify_pairs(primer_client):
resp = primer_client.post("/api/primers/design", json={
"sequence": TP53_CDS,
"product_size_min": 100,
"product_size_max": 500,
"opt_tm": 60,
"num_return": 5,
})
assert resp.status_code == 200, resp.text
pairs = resp.json()
assert len(pairs) >= 1
template = TP53_CDS
for p in pairs:
# Primer3 coordinate invariants
assert p["product_size"] == p["right_pos"] - p["left_pos"] + 1, (
f"pair {p['pair_index']}: product size inconsistent with positions"
)
# Primers are valid DNA of Primer3-default length
assert 18 <= p["left_len"] <= 25 and 18 <= p["right_len"] <= 25
assert set(p["left_seq"]) <= set("ATGCN") and set(p["right_seq"]) <= set("ATGCN")
# Forward primer occurs at the reported position
assert template[p["left_pos"]:p["left_pos"] + p["left_len"]] == p["left_seq"], (
f"pair {p['pair_index']}: forward primer not at reported position"
)
# Reverse primer is the reverse complement of the template region
r_start = p["right_pos"] - p["right_len"] + 1
assert template[r_start:p["right_pos"] + 1] == reverse_complement(p["right_seq"]), (
f"pair {p['pair_index']}: reverse primer mismatch in template"
)
# In-silico PCR reproduces Primer3's product size & coordinates
pcr = in_silico_pcr(template, p["left_seq"], p["right_seq"],
expected_product=p["product_size"],
left_expected=p["left_pos"], right_expected=p["right_pos"])
assert pcr["primer3_consistent"], f"pair {p['pair_index']}: in-silico PCR disagrees with Primer3"
assert pcr["matches_product_size"] is True, f"pair {p['pair_index']}: amplicon length mismatch"
def test_analyze_endpoint_reference_pair(primer_client):
# Known-good pair spanning the full TP53 CDS (NM_000546.6):
# forward binds CDS index 0, reverse primer 3' end maps to index 1181
# (its target region is 1162..1181), amplicon = 1182 bp.
left, right = "ATGGAGGAGCCGCAGTCAGA", "TCAGTCTGAGTCAGGCCCTT"
resp = primer_client.post("/api/primers/analyze", json={
"left_seq": left,
"right_seq": right,
"template": TP53_CDS,
"expected_product": 1182,
"left_pos": 0,
"right_pos": 1181,
})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["qc"]["left"]["gc"] > 40.0 and body["qc"]["left"]["gc"] < 70.0
assert 45 <= body["qc"]["left"]["tm_50mM"] <= 70
assert "hairpin" in body["qc"]["left"]
assert "self_dimer" in body["qc"]["left"]
assert "hetero_dimer" in body["qc"]
assert body["pcr"]["primer3_consistent"] is True
assert body["pcr"]["matches_product_size"] is True
def test_analyze_endpoint_rejects_protein(primer_client):
resp = primer_client.post("/api/primers/analyze", json={
"left_seq": "MKFLVLFLLGLVA",
"right_seq": "ACDEFGHIKLMNP",
})
assert resp.status_code == 400
|