""" 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("") == "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