Spaces:
Running
Running
| """Unit tests for Plasmid Studio core (dee/core/plasmid.py). | |
| Uses Biopython locally (no network).""" | |
| import pytest | |
| from dee.core import plasmid as PL | |
| # T7 promoter (18) + EcoRI site + 20 + EcoRI site + 20 = 70 bp, 2 EcoRI sites. | |
| SEQ = ("TAATACGACTCACTATAG" + "GAATTC" + "AAAAGGGGAAAAGGGGAAAA" | |
| + "GAATTC" + "CCCCTTTTCCCCTTTTCCCC") | |
| def test_revcomp_gc(): | |
| assert PL.revcomp("ATGC") == "GCAT" | |
| assert PL.gc_percent("GGCC") == 100.0 | |
| assert PL.gc_percent("ATAT") == 0.0 | |
| def test_parse_raw_dna(): | |
| p = PL.parse_plasmid(SEQ) | |
| assert p["source"] == "raw" | |
| assert p["length"] == len(SEQ) | |
| assert p["topology"] == "circular" | |
| assert p["features"] == [] | |
| def test_parse_fasta(): | |
| p = PL.parse_plasmid(">myseq description here\n" + SEQ) | |
| assert p["source"] == "fasta" | |
| assert p["name"] == "myseq" | |
| assert p["sequence"] == SEQ | |
| def test_parse_topology_override(): | |
| p = PL.parse_plasmid(SEQ, topology="linear") | |
| assert p["topology"] == "linear" | |
| def test_parse_rejects_empty_and_nondna(): | |
| with pytest.raises(ValueError): | |
| PL.parse_plasmid(" ") | |
| with pytest.raises(ValueError): | |
| PL.parse_plasmid("hello world this is prose not dna @@@") | |
| def test_parse_rejects_oversize(monkeypatch): | |
| monkeypatch.setattr(PL, "MAX_LEN", 50) | |
| with pytest.raises(ValueError): | |
| PL.parse_plasmid("A" * 60) | |
| def test_parse_rejects_oversize_input_text(monkeypatch): | |
| monkeypatch.setattr(PL, "MAX_INPUT_TEXT", 1000) | |
| with pytest.raises(ValueError): | |
| PL.parse_plasmid("A" * 2000) | |
| # ββ sanitize_features (audit hardening) βββββββββββββββββββββββββββββββββββββ | |
| def test_sanitize_features_forces_safe_color(): | |
| bad = [{"name": "x", "type": "cds", "start": 0, "end": 5, | |
| "color": 'red" onmouseover="alert(1)'}] | |
| out = PL.sanitize_features(bad, 100) | |
| assert out[0]["color"] == PL._DEFAULT_COLOR # injection scrubbed | |
| ok = PL.sanitize_features([{"start": 0, "end": 5, "color": "#6A89E4"}], 100) | |
| assert ok[0]["color"] == "#6A89E4" # valid hex kept | |
| def test_sanitize_features_clamps_and_drops(): | |
| feats = [ | |
| {"start": -10, "end": 50}, # start clamped to 0 | |
| {"start": 90, "end": 9999}, # end clamped to len | |
| {"start": 60, "end": 60}, # empty β dropped | |
| {"start": "x", "end": 5}, # non-int β dropped | |
| "not a dict", # β dropped | |
| ] | |
| out = PL.sanitize_features(feats, 100) | |
| assert len(out) == 2 | |
| assert out[0]["start"] == 0 and out[0]["end"] == 50 | |
| assert out[1]["end"] == 100 | |
| def test_sanitize_features_caps_count(monkeypatch): | |
| monkeypatch.setattr(PL, "MAX_FEATURES", 10) | |
| out = PL.sanitize_features([{"start": 0, "end": 3} for _ in range(50)], 100) | |
| assert len(out) == 10 | |
| def test_sanitize_features_normalizes_strand(): | |
| out = PL.sanitize_features([{"start": 0, "end": 5, "strand": "-"}, | |
| {"start": 0, "end": 5, "strand": 5}], 100) | |
| assert out[0]["strand"] == -1 and out[1]["strand"] == 1 | |
| # ββ annotation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_find_motifs_t7_promoter(): | |
| feats = PL.find_motifs(SEQ) | |
| names = [f["name"] for f in feats] | |
| assert "T7 promoter" in names | |
| t7 = next(f for f in feats if f["name"] == "T7 promoter") | |
| assert t7["start"] == 0 and t7["strand"] == 1 | |
| def test_find_orfs(): | |
| # ATG + 100 sense codons (no stop) + TAA | |
| orf = "ATG" + "GCT" * 100 + "TAA" | |
| feats = PL.find_orfs("CCCC" + orf + "CCCC", min_aa=50) | |
| assert any(f["type"] == "CDS" for f in feats) | |
| def test_find_orfs_linear_on_stopless_input(): | |
| # Audit DoS regression: a long, ATG-rich, stop-LESS sequence must return | |
| # fast (linear) and find nothing (no stop closes an ORF). | |
| import time | |
| seq = "ATG" * 40_000 # 120 kb, every codon an ATG, no stop | |
| t0 = time.time() | |
| feats = PL.find_orfs(seq, min_aa=50) | |
| assert feats == [] | |
| assert time.time() - t0 < 2.0 # would be many seconds under the old O(nΒ²) | |
| def test_find_orfs_caps_count(): | |
| # many short ORFs back-to-back β capped, doesn't explode | |
| unit = "ATG" + "GCT" * 60 + "TAA" # ~61 aa ORF | |
| feats = PL.find_orfs(unit * 1200, min_aa=50, max_orfs=100) | |
| assert len(feats) <= 100 | |
| def test_auto_annotate_only_when_empty(): | |
| p = PL.parse_plasmid(SEQ) | |
| PL.auto_annotate(p) | |
| assert p.get("auto_annotated") is True | |
| assert any(f["name"] == "T7 promoter" for f in p["features"]) | |
| # second call is a no-op (features already present) | |
| before = list(p["features"]) | |
| PL.auto_annotate(p) | |
| assert p["features"] == before | |
| # ββ GenBank round-trip βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_genbank_roundtrip(): | |
| p = PL.parse_plasmid(SEQ, topology="circular") | |
| p["name"] = "pTEST" | |
| p["features"] = [{"name": "T7 promoter", "type": "promoter", "start": 0, | |
| "end": 18, "strand": 1, "color": "#5FA98A", "notes": ""}] | |
| gb = PL.build_genbank(p) | |
| assert "LOCUS" in gb and "promoter" in gb | |
| re_p = PL.parse_plasmid(gb) | |
| assert re_p["source"] == "genbank" | |
| assert re_p["sequence"] == SEQ | |
| assert re_p["topology"] == "circular" | |
| assert any(f["type"] == "promoter" for f in re_p["features"]) | |
| def test_build_fasta(): | |
| p = PL.parse_plasmid(SEQ) | |
| fa = PL.build_fasta(p) | |
| assert fa.startswith(">") | |
| assert "".join(fa.split("\n")[1:]) == SEQ | |
| # ββ restriction + digest βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_restriction_finds_ecori_sites(): | |
| r = PL.restriction_analysis(SEQ, "circular", ["EcoRI", "BamHI"]) | |
| ecori = next(e for e in r["enzymes"] if e["name"] == "EcoRI") | |
| assert ecori["count"] == 2 | |
| assert "EcoRI" in r["double_cutters"] | |
| assert "BamHI" in r["non_cutters"] | |
| def test_restriction_default_common_set(): | |
| r = PL.restriction_analysis(SEQ, "circular") | |
| assert len(r["enzymes"]) == len(PL.COMMON_ENZYMES) | |
| def test_fragments_linear_and_circular(): | |
| # cuts at positions 3 and 23 in a 70-mer | |
| lin = PL._fragments([3, 23], 70, circular=False) | |
| assert sum(lin) == 70 and len(lin) == 3 | |
| circ = PL._fragments([3, 23], 70, circular=True) | |
| assert sum(circ) == 70 and len(circ) == 2 | |
| # single cut on a circle β one full-length linear fragment | |
| assert PL._fragments([10], 70, circular=True) == [70] | |
| # uncut β one fragment | |
| assert PL._fragments([], 70, circular=True) == [70] | |
| def test_digest_two_cutter_circular_gives_two_fragments(): | |
| d = PL.digest(SEQ, "circular", ["EcoRI"]) | |
| assert d["n_fragments"] == 2 | |
| assert sum(d["fragments"]) == len(SEQ) | |
| assert len(d["cut_positions"]) == 2 | |
| def test_digest_linear_one_cutter_gives_two_fragments(): | |
| # SmaI? use EcoRI but linear: 2 sites β 3 fragments | |
| d = PL.digest(SEQ, "linear", ["EcoRI"]) | |
| assert d["n_fragments"] == 3 | |
| assert sum(d["fragments"]) == len(SEQ) | |
| # ββ Common-features auto-annotation library (2026-06) ββββββββββββββββββββββββ | |
| def test_common_features_detected_both_strands(): | |
| """The expanded _MOTIFS library annotates common tags / sites / promoters | |
| on both strands, like SnapGene/Benchling.""" | |
| parts = [ | |
| "TAATACGACTCACTATAG", # T7 promoter (+) | |
| "ACGT" * 5, | |
| "ATAACTTCGTATAATGTATGCTATACGAAGTTAT", # loxP | |
| "GGGG" * 4, | |
| "GATTACAAGGATGATGACGATAAG", # FLAG | |
| "TT", | |
| "CATCACCATCACCATCAC", # 6xHis (CAT variant) | |
| "AAAACCCGGGTTT" * 3, | |
| "ACAAGTTTGTACAAAAAAGCAGGCT", # attB1 | |
| ] | |
| feats = PL.find_motifs("".join(parts)) | |
| names = {f["name"] for f in feats} | |
| assert {"T7 promoter", "loxP", "FLAG tag", "6ΓHis tag", "attB1"} <= names | |
| # reverse-strand detection | |
| rc = PL.revcomp("GAAGTTCCTATTCTCTAGAAAGTATAGGAACTTC") # FRT | |
| rev = PL.find_motifs("CCCC" + rc + "GGGG") | |
| assert any(f["name"] == "FRT" and f.get("strand") == -1 for f in rev) | |