File size: 17,924 Bytes
1c2cf5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
1c2cf5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2cf5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
 
 
 
 
d8958b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
d8958b0
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2cf5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c2cf5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4b491b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
"""
Sequence utilities toolkit, motif scanner and dot plot tests.

Known-answer checks against hand-verified arithmetic:

  * reverse complement / GC% / MW for a short oligo
  * translation frames and longest-ORF detection on a mini CDS
  * restriction-site positions on a sequence containing EcoRI + BamHI sites
  * PROSITE pattern parsing (alternation, exclusion, repeats, anchors)
  * motif library hits on a small protein containing N-glycosylation sites
  * dot-plot geometry (diagonal for identical sequences, off-diagonal dot for
    a shared repeat, window/stringency behavior)
  * router error mapping (bad pattern / too-large dot plot -> HTTP 400)
"""

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.sequence_utilities import analyze_sequence, clean_sequence
from app.tools.motif_scanner import (
    MOTIF_LIBRARY,
    prosite_to_regex,
    scan_library,
    scan_pattern,
)
from app.tools.dotplot import compute_dotplot

# p53 residues 1-70 with two N-glycosylation consensus hits (N-S-T, N-C-S)
P53_N_TERM = (
    "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAA"
)


@pytest.fixture(scope="module")
def seq_client():
    app = FastAPI(title="Seq Tools Tests")
    from app.routers import seq_tools
    app.include_router(seq_tools.router)
    with TestClient(app, raise_server_exceptions=False) as c:
        yield c


# ============================================================================
# 1. Sequence utilities
# ============================================================================

class TestAnalyzeSequence:
    def test_dna_reverse_complement_and_gc(self):
        res = analyze_sequence("ATGCGAATTCGGATCC", seq_type="dna")
        assert res["sequence_type"] == "dna"
        assert res["length"] == 16
        assert res["reverse_complement"] == "GGATCCGAATTCGCAT"
        # G=4, C=4 in ATGCGAATTCGGATCC
        assert res["gc_content"] == pytest.approx(8 / 16 * 100, abs=0.1)

    def test_restriction_sites_ecoRI_bamHI(self):
        res = analyze_sequence("ATGCGAATTCGGATCC", seq_type="dna")
        names = {s["name"]: s for s in res["restriction_sites"]}
        assert "EcoRI" in names and "BamHI" in names
        # EcoRI site (GAATTC) starts at index 5, BamHI (GGATCC) at index 11
        assert names["EcoRI"]["positions"] == [5]
        assert names["BamHI"]["positions"] == [11]

    def test_translation_frames_and_best_orf(self):
        # ATG CAT TAA CGT GCA TGA -> frame 1 = "MH*RA*"; best ORF = MH (stops)
        res = analyze_sequence("ATGCATTAACGTGCATGAA", seq_type="dna")
        tr = res["translation"]
        assert tr is not None
        assert tr["frames"]["1"] == "MH*RA*"
        assert tr["best"]["frame"] == 1
        assert tr["best"]["protein"] == "MH"
        assert tr["best"]["has_stop"] is True

    def test_protein_mw_and_composition(self):
        res = analyze_sequence("MEEPQSDPSVEP", seq_type="protein")
        assert res["sequence_type"] == "protein"
        assert res["molecular_weight"] is not None and res["molecular_weight"] > 0
        comp = {c["aa"]: c["count"] for c in res["aa_composition"]}
        assert comp["M"] == 1 and comp["E"] == 3 and comp["P"] == 3
        total_pct = round(sum(c["pct"] for c in res["aa_composition"]), 1)
        assert total_pct == pytest.approx(100.0, abs=0.1)

    def test_fasta_input_handled(self):
        res = analyze_sequence(">probe\nATGCGAATTCGGATCC\n", seq_type="dna")
        assert res["length"] == 16

    def test_auto_detects_dna(self):
        res = analyze_sequence("ACGTACGTTGCA", seq_type="auto")
        assert res["sequence_type"] == "dna"

    def test_empty_raises(self):
        with pytest.raises(Exception):
            analyze_sequence("   ", seq_type="dna")

    def test_clean_drops_ambiguous_for_gc(self):
        cleaned = clean_sequence("ATGCNNNGG", "dna")
        assert set(cleaned) <= set("ATCGN")


# ============================================================================
# 2. Motif scanner
# ============================================================================

class TestPrositeToRegex:
    def test_literal_and_x(self):
        assert prosite_to_regex("A-x-V") == "A[A-Z]V"

    def test_alternation_and_exclusion(self):
        assert prosite_to_regex("[ST]-x-[RK]") == "[ST][A-Z][RK]"
        assert prosite_to_regex("N-{P}-[ST]-{P}") == "N[^P][ST][^P]"

    def test_repeat_quantifiers(self):
        assert prosite_to_regex("x(2,4)") == "[A-Z]{2,4}"
        assert prosite_to_regex("[ST]-x(2)-[DE]") == "[ST][A-Z]{2}[DE]"

    def test_anchors(self):
        assert prosite_to_regex("<A") == "^A"
        assert prosite_to_regex("G>") == "G$"

    def test_invalid_symbol_raises(self):
        from app.tools.motif_scanner import MotifError
        with pytest.raises(MotifError):
            prosite_to_regex("A-*")

    def test_invalid_group_raises(self):
        from app.tools.motif_scanner import MotifError
        with pytest.raises(MotifError):
            prosite_to_regex("[1]x")
        with pytest.raises(MotifError):
            prosite_to_regex("A-@-B")


class TestScanPattern:
    def test_nglycosylation_finds_expected(self):
        # N-glycosylation consensus N-{P}-[ST]-{P}: "QNSTL" -> match "NSTL"
        res = scan_pattern("MKDYQNSTLPVARKTGH", "N-{P}-[ST]-{P}")
        assert res["count"] >= 1
        for m in res["matches"]:
            assert m["end"] - m["start"] + 1 == 4

    def test_overlapping_matches_reported(self):
        res = scan_pattern("NNSTP", "N-{P}-[ST]-{P}")
        # position 1 starts "NNST" -> one match
        assert res["count"] == 1

    def test_cleans_sequence_case(self):
        res = scan_pattern("Mnnstlpv", "N-{P}-[ST]-{P}")
        assert res["count"] == 1


class TestScanLibrary:
    def test_known_motifs_found(self):
        # This construct has two obvious hits: N-glycosylation N-S-T and PKC [ST]-x-[RK]
        seq = "MKDYQNSTLPVARKTGH"
        res = scan_library(seq)
        assert res["motifs_found"] >= 1
        names = {h["name"] for h in res["hits"]}
        assert "N-glycosylation site" in names

    def test_library_reports_length(self):
        res = scan_library(P53_N_TERM)
        assert res["length"] == len(P53_N_TERM)

    def test_library_hits_carry_metadata(self):
        res = scan_library("MKDYQNSTLPVARKTGH")
        assert res["motifs_found"] >= 1
        for hit in res["hits"]:
            assert "accession" in hit
            assert "category" in hit
            assert hit["specificity"] in ("high", "loose")

    def test_library_category_filter(self):
        res = scan_library("MKDYQNSTLPVARKTGH", categories=["PTM"])
        assert res["motifs_found"] >= 1
        assert all(h["category"] == "PTM" for h in res["hits"])
        # A filter that matches nothing yields an empty hit list
        res_none = scan_library("MKDYQNSTLPVARKTGH", categories=["RNA binding"])
        assert res_none["motifs_found"] == 0
        assert res_none["patterns_scanned"] < len(MOTIF_LIBRARY)

    def test_ptm_filter_keeps_glycosylation(self):
        res = scan_library("MKDYQNSTLPVARKTGH", categories=["PTM"])
        names = {h["name"] for h in res["hits"]}
        assert "N-glycosylation site" in names


# ============================================================================
# 3. Dot plot
# ============================================================================

class TestDotPlot:
    def test_identical_sequences_diagonal(self):
        # Every window position trivially matches itself, so a self-plot lights
        # the whole main diagonal and nothing off it.
        res = compute_dotplot("AAAACCCCGGGGTTTT", "AAAACCCCGGGGTTTT", window=4, stringency=100)
        assert res["dot_count"] == 16 - 4 + 1
        for y, x in res["dots"]:
            assert y == x
        assert res["seq_a_length"] == 16 and res["seq_b_length"] == 16

    def test_shared_repeat_off_diagonal(self):
        a = "AAAACCCCGGGGTTTT"
        b = "AAAAGGGGAAAACCCC"
        res = compute_dotplot(a, b, window=4, stringency=100)
        coords = set(map(tuple, res["dots"]))
        # "AAAA" appears in both at position 0; "CCCC" in a at 4 and b at 12
        assert (0, 0) in coords
        assert (4, 12) in coords

    def test_stringency_filters_weak_matches(self):
        a = "AAAAAAAAAA"
        b = "AAAAAAAAAG"  # differs at last position
        # window 5, stringency 100 -> only positions with 5/5 identical
        res = compute_dotplot(a, b, window=5, stringency=100)
        assert (0, 0) in {tuple(d) for d in res["dots"]}
        # window 5, stringency 80 -> last position (4, 4) matches 4/5
        res80 = compute_dotplot(a, b, window=5, stringency=80)
        assert (4, 4) in {tuple(d) for d in res80["dots"]}

    def test_window_one_is_char_match(self):
        res = compute_dotplot("ACGT", "ACGT", window=1, stringency=100)
        assert res["dot_count"] == 4

    def test_blosum62_matrix_scoring(self):
        res = compute_dotplot("WWWWWWWW", "WWWWWWWW", window=2, stringency=100, scoring="blosum62")
        assert res["scoring_used"] == "blosum62"
        # W-W = 11, so a 2-window at 100% stringency needs a score of 22;
        # because the sequence is uniform, every 2-window matches every other
        # 2-window, lighting the whole 7x7 matrix (correct dot-plot semantics).
        assert res["threshold"] == 22
        assert res["dot_count"] == 7 * 7
        assert res["features"]["main_diagonal_pct"] == 100.0

    def test_substitution_threshold_is_self_normalized(self):
        # Regression: stringency was previously a % of the global matrix max
        # (11 = W-W), so a window of alanines (4/window) never reached the
        # threshold and identical A-rich proteins rendered a nearly empty plot.
        # With per-window "percent of perfect self-match" semantics, an
        # identical A-rich protein must light its whole main diagonal.
        seq = "MAAAAAAAAAAAAAAAM"
        res = compute_dotplot(seq, seq, window=10, stringency=80, scoring="blosum62")
        assert res["match_rule"] == "percent_of_perfect_self_match"
        assert res["features"]["main_diagonal_pct"] == 100.0
        # Every diagonal window-start must light; off-diagonal dots are the
        # legitimate repetitive A-stretches, so only require the diagonal.
        expected = {i for i in range(res["seq_a_length"] - res["window"] + 1)}
        on_diag = {y for y, x in res["dots"] if y == x}
        assert on_diag == expected

    def test_conserved_protein_lights_main_diagonal(self):
        # A real, conserved protein compared to itself must trace a clear
        # diagonal with default BLOSUM62 / window 10 / 80% settings.
        p53 = ("MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEA"
               "PRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCT"
               "YSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDG"
               "LAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRR"
               "PILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNN"
               "TSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKK"
               "GQSTSRHKKLMFKTEGPDSD")
        res = compute_dotplot(p53, p53, window=10, stringency=80, scoring="blosum62")
        assert res["features"]["main_diagonal_pct"] >= 90

    def test_scoring_falls_back_to_identity_for_nucleotides(self):
        res = compute_dotplot("AAAACCCC", "AAAACCCC", window=2, stringency=100, scoring="blosum62")
        assert res["scoring"] == "blosum62"
        assert res["scoring_used"] == "identity"
        assert res["threshold"] == 2

    def test_mixed_protein_nucleotide_falls_back_to_identity(self):
        # Regression: only seq_a was type-checked, so a protein-vs-DNA
        # comparison used a BLOSUM matrix on nucleotide letters (A->Ala etc.),
        # producing spurious off-diagonal dots. Either non-protein input must
        # force identity scoring and report the mixed type.
        res = compute_dotplot("MEEPQSDPSVEP", "ATGCATGCATGCA", scoring="blosum62")
        assert res["scoring_used"] == "identity"
        assert res["sequence_type"] == "mixed"
        assert res["sequence_type_a"] == "protein"
        assert res["sequence_type_b"] == "dna"

    def test_invalid_scoring_raises(self):
        from app.tools.dotplot import DotPlotError
        with pytest.raises(DotPlotError):
            compute_dotplot("AAAA", "AAAA", scoring="bogus")

    def test_features_identical_main_diagonal(self):
        res = compute_dotplot("AAAACCCCGGGGTTTT", "AAAACCCCGGGGTTTT", window=4, stringency=100)
        f = res["features"]
        # 13 achievable window-start positions along the diagonal (16 - 4 + 1)
        assert f["main_diagonal_pct"] == 100.0
        assert f["off_diagonal"] == []
        assert f["anti_diagonal"] == []

    def test_features_detect_repeat_offset(self):
        a = "AAAACCCCGGGGTTTT"
        b = "TTTTAAAACCCCGGGG"
        res = compute_dotplot(a, b, window=4, stringency=100)
        offsets = {o["offset"]: o["count"] for o in res["features"]["off_diagonal"]}
        # AAAA/CCCC/GGGG sit at b[4], b[8], b[12] -> three dots at offset +4
        assert offsets.get(4, 0) >= 3

    def test_features_detect_inverted_repeat(self):
        a = "AAAACCCCGGGGTTTT"
        b = a[::-1]
        res = compute_dotplot(a, b, window=4, stringency=100)
        sums = {o["sum"]: o["count"] for o in res["features"]["anti_diagonal"]}
        # Reverse comparison lights the anti-diagonal x + y = 12
        assert sums.get(12, 0) >= 4

    def test_too_large_raises(self):
        from app.tools.dotplot import DotPlotError
        with pytest.raises(DotPlotError):
            compute_dotplot("A" * 5000, "A" * 5000)


# ============================================================================
# 4. Router-level
# ============================================================================

class TestSeqToolsEndpoints:
    def test_analyze_endpoint(self, seq_client):
        resp = seq_client.post("/api/seq-tools/analyze", json={
            "sequence": "ATGCGAATTCGGATCC", "seq_type": "dna",
        })
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["reverse_complement"] == "GGATCCGAATTCGCAT"
        assert body["length"] == 16

    def test_analyze_bad_type_400(self, seq_client):
        resp = seq_client.post("/api/seq-tools/analyze", json={
            "sequence": "ACGT", "seq_type": "bogus",
        })
        assert resp.status_code == 400

    def test_motif_scan_endpoint(self, seq_client):
        resp = seq_client.post("/api/seq-tools/motif-scan", json={
            "sequence": "MNSST", "pattern": "N-{P}-[ST]-{P}",
        })
        assert resp.status_code == 200, resp.text
        assert resp.json()["count"] >= 1

    def test_motif_scan_bad_pattern_400(self, seq_client):
        resp = seq_client.post("/api/seq-tools/motif-scan", json={
            "sequence": "MNSST", "pattern": "A-*",
        })
        assert resp.status_code == 400

    def test_motif_library_endpoint(self, seq_client):
        resp = seq_client.post("/api/seq-tools/motif-library", json={
            "sequence": "MKDYQNSTLPVARKTGH",
        })
        assert resp.status_code == 200, resp.text
        assert resp.json()["motifs_found"] >= 1

    def test_motif_patterns_list(self, seq_client):
        resp = seq_client.get("/api/seq-tools/motif-library/patterns")
        assert resp.status_code == 200
        assert len(resp.json()) >= 5
        assert "accession" in resp.json()[0]
        assert "category" in resp.json()[0]
        assert "specificity" in resp.json()[0]

    def test_motif_categories_list(self, seq_client):
        resp = seq_client.get("/api/seq-tools/motif-library/categories")
        assert resp.status_code == 200
        cats = resp.json()
        assert isinstance(cats, list) and "PTM" in cats
        assert cats == sorted(cats, key=lambda c: cats.index(c))  # ordered, unique

    def test_motif_library_category_filter_endpoint(self, seq_client):
        resp = seq_client.post("/api/seq-tools/motif-library", json={
            "sequence": "MKDYQNSTLPVARKTGH",
            "categories": ["PTM"],
        })
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["motifs_found"] >= 1
        assert all(h["category"] == "PTM" for h in body["hits"])

    def test_dotplot_endpoint(self, seq_client):
        resp = seq_client.post("/api/seq-tools/dotplot", json={
            "seq_a": "AAAACCCCGGGGTTTT", "seq_b": "AAAACCCCGGGGTTTT",
            "window": 4, "stringency": 100,
        })
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["dot_count"] == 16 - 4 + 1
        assert all(d[0] == d[1] for d in body["dots"])

    def test_dotplot_too_large_400(self, seq_client):
        resp = seq_client.post("/api/seq-tools/dotplot", json={
            "seq_a": "A" * 5000, "seq_b": "A" * 5000,
            "window": 4, "stringency": 100,
        })
        assert resp.status_code == 400

    def test_dotplot_scoring_endpoint(self, seq_client):
        resp = seq_client.post("/api/seq-tools/dotplot", json={
            "seq_a": "WWWWWWWW", "seq_b": "WWWWWWWW",
            "window": 2, "stringency": 100, "scoring": "blosum62",
        })
        assert resp.status_code == 200, resp.text
        body = resp.json()
        assert body["scoring_used"] == "blosum62"
        assert body["features"]["main_diagonal_pct"] == 100.0

    def test_dotplot_bad_scoring_400(self, seq_client):
        resp = seq_client.post("/api/seq-tools/dotplot", json={
            "seq_a": "AAAA", "seq_b": "AAAA", "scoring": "bogus",
        })
        assert resp.status_code == 400