syntheogenesis / tests /test_clone_sim.py
github-actions[bot]
Deploy a4e181b
d87fc13
Raw History Blame Contribute Delete
12.4 kB
"""Unit tests for the cloning simulator (dee/core/cloning.py)."""
import pytest
from dee.core import cloning as C
# ── Gibson / overlap assembly ───────────────────────────────────────────────
def test_gibson_native_overlaps_circular():
# three fragments sharing 20-bp overlaps, arranged head-to-tail in a circle.
o1 = "ACGTACGTACGTACGTACGT" # A↔B overlap
o2 = "TTTTGGGGCCCCAAAATTTT" # B↔C overlap
o3 = "GGGGCCCCAAAATTTTGGGG" # C↔A overlap
A = o3 + "AAAAAAAAAAAA" + o1
B = o1 + "CCCCCCCCCCCC" + o2
Cc = o2 + "GGGGGGGGGGGG" + o3
out = C.gibson([{"name": "A", "seq": A}, {"name": "B", "seq": B}, {"name": "C", "seq": Cc}])
assert out["ok"] is True
assert out["topology"] == "circular"
assert all(j["kind"] == "native" for j in out["junctions"])
assert out["length"] == len(A) + len(B) + len(Cc) - 60 # minus three 20-bp overlaps
def test_gibson_auto_orders_fragments():
o1 = "ACGTACGTACGTACGTACGT"
o2 = "TTTTGGGGCCCCAAAATTTT"
A = "AAAAAAAA" + o1
B = o1 + "CCCCCCCC" + o2
Cc = o2 + "GGGGGGGG"
out = C.gibson([{"name": "C", "seq": Cc}, {"name": "A", "seq": A}, {"name": "B", "seq": B}],
circular=False)
assert out["ok"] is True
assert out["order"] == ["A", "B", "C"]
assert out["length"] == len(A) + len(B) + len(Cc) - 40
def test_gibson_designs_overlaps_and_primers_for_bare_fragments():
A = "ATGGTGAGCAAGGGCGAGGAGCTGTTCACC"
B = "GACGAGCTGTACAAGTAACTCGAGCTAGCAT"
out = C.gibson([{"name": "A", "seq": A}, {"name": "B", "seq": B}], circular=False)
assert out["ok"] is True
assert any(j["kind"] == "designed" for j in out["junctions"])
assert out["length"] == len(A) + len(B)
prim = {p["fragment"]: p for p in out["primers"]}
assert prim["B"]["forward_tail"] > 0
assert prim["B"]["forward"].startswith(A[-25:])
def test_gibson_needs_two_fragments():
assert C.gibson([{"name": "x", "seq": "ACGT"}])["ok"] is False
# ── Golden Gate ──────────────────────────────────────────────────────────────
def _bsa_part(left_oh, body, right_oh):
return "GAATT" + "GGTCTC" + "T" + left_oh + body + right_oh + "T" + "GAGACC" + "TTAAG"
def test_excise_part_reads_overhangs():
p = C._excise_part(_bsa_part("AAAA", "GCTAGCGGGG", "CCCC"), "BsaI")
assert p["left_oh"] == "AAAA"
assert p["right_oh"] == "CCCC"
assert p["inner"] == "AAAA" + "GCTAGCGGGG" + "CCCC"
def test_golden_gate_assembles_circle():
p1 = _bsa_part("AAAA", "GCTAGCAAAA", "CCCC")
p2 = _bsa_part("CCCC", "TTGGCCAATT", "AAAA")
out = C.golden_gate([{"name": "P1", "seq": p1}, {"name": "P2", "seq": p2}], enzyme="BsaI")
assert out["ok"] is True
assert out["topology"] == "circular"
assert out["length"] == (4 + len("GCTAGCAAAA")) + (4 + len("TTGGCCAATT"))
assert out["order"] == ["P1", "P2"]
def test_golden_gate_rejects_mismatched_overhangs():
p1 = _bsa_part("AAAA", "GCTAGCAAAA", "CCCC")
p2 = _bsa_part("GGGG", "TTGGCCAATT", "AAAA")
out = C.golden_gate([{"name": "P1", "seq": p1}, {"name": "P2", "seq": p2}], enzyme="BsaI")
assert out["ok"] is False
def test_golden_gate_rejects_non_cassette():
out = C.golden_gate([{"name": "P1", "seq": "ACGTACGTACGT"}, {"name": "P2", "seq": "TTTTGGGG"}])
assert out["ok"] is False
# ── restriction cut-and-ligate ──────────────────────────────────────────────
def test_restriction_clone_single_enzyme():
vector = "AAAACCCC" + "GAATTC" + "TTTTGGGGCCCCAAAA"
insert = "CC" + "GAATTC" + "ATGCATGCATGCATGC" + "GAATTC" + "GG"
out = C.restriction_clone(vector, insert, "EcoRI", vector_topology="circular")
assert out["ok"] is True
assert out["topology"] == "circular"
assert "ATGCATGCATGCATGC" in out["assembled"]
assert out["length"] > len(vector)
def test_restriction_clone_linear_splices_the_insert_in_at_the_cut():
# A single cut on a LINEAR vector produces two loose ends, not a seam to
# close β€” the correct operation is splicing the insert IN at the cut
# (left arm, insert, right arm), not gluing the two arms back together
# unchanged and appending the insert onto the end (the original bug: a
# silent "ok": True carrying the untouched vector plus a dangling tail,
# mislabelled "topology": "circular" on top of that).
left, right = "AAAACCCC", "TTTTGGGGCCCCAAAA"
vector = left + "GAATTC" + right
insert = "CC" + "GAATTC" + "ATGCATGCATGCATGC" + "GAATTC" + "GG"
out = C.restriction_clone(vector, insert, "EcoRI", vector_topology="linear")
assert out["ok"] is True
assert out["topology"] == "linear"
# The insert lands BETWEEN the two original arms β€” not after them.
assert out["assembled"].startswith(left)
assert out["assembled"].endswith(right)
assert "ATGCATGCATGCATGC" in out["assembled"]
insert_start = out["assembled"].index("ATGCATGCATGCATGC")
assert insert_start < len(out["assembled"]) - len(right)
# Nothing lost, nothing silently kept whole: length is vector + insert
# fragment exactly, not vector + insert (no-op backbone) or vector alone.
i0 = insert.index("GAATTC")
i1 = insert.rindex("GAATTC")
insert_frag_len = i1 - i0
assert out["length"] == len(vector) + insert_frag_len
assert len(out["features"]) == 3
def test_restriction_clone_requires_unique_vector_site():
vector = "GAATTCAAAAGAATTC"
insert = "GAATTCATGCGAATTC"
out = C.restriction_clone(vector, insert, "EcoRI", vector_topology="linear")
assert out["ok"] is False
def test_restriction_clone_requires_flanked_insert():
vector = "AAAAGAATTCTTTT"
insert = "ATGCATGCATGC"
out = C.restriction_clone(vector, insert, "EcoRI")
assert out["ok"] is False
# ── Yeast in-vivo (homologous-recombination) assembly ───────────────────────
import random as _random
def _ydna(rng, n):
return "".join(rng.choice("ACGT") for _ in range(n))
def test_yeast_designs_arms_primers_and_plan():
rng = _random.Random(11)
vec, gA, gB = _ydna(rng, 240), _ydna(rng, 180), _ydna(rng, 180)
frags = [{"name": "pRS416 URA3 vector", "seq": vec},
{"name": "geneA", "seq": gA}, {"name": "geneB", "seq": gB}]
r = C.yeast_assembly(frags, circular=True)
assert r["ok"] and r["method"] == "yeast_hr" and r["topology"] == "circular"
assert r["order"] == ["pRS416 URA3 vector", "geneA", "geneB"]
# distinct fragments β†’ every junction is a designed, UNIQUE homology arm
assert all(j["kind"] == "designed" for j in r["junctions"])
assert all(j["unique"] for j in r["junctions"])
assert not any("share homology" in w for w in r["warnings"])
# chimeric primers carry a 40-bp homology tail
assert any(p["forward_tail"] == C.YEAST_ARM for p in r["primers"])
# marker detected from the vector name; plan + one verification pair per junction
assert r["marker"] == "pRS416 URA3 vector"
assert set(r["plan"]) == {"transform", "selection", "verify"}
assert len(r["verification"]) == len(r["junctions"])
assert all(v["forward"] and v["reverse"] for v in r["verification"])
def test_yeast_flags_repeated_terminator_as_misassembly_risk():
rng = _random.Random(12)
term = _ydna(rng, 45) # a terminator reused on two genes
vec = _ydna(rng, 220)
gA = _ydna(rng, 150) + term
gB = _ydna(rng, 150) + term # SAME terminator at the 3' end
frags = [{"name": "vec LEU2", "seq": vec},
{"name": "geneA-tCYC1", "seq": gA}, {"name": "geneB-tCYC1", "seq": gB}]
r = C.yeast_assembly(frags, circular=True)
assert r["ok"]
# the two junctions reading off the repeated terminator share homology
assert sum(1 for j in r["junctions"] if not j["unique"]) >= 2
assert any("share homology" in w for w in r["warnings"])
def test_yeast_missing_marker_warns_and_can_be_named():
rng = _random.Random(13)
frags = [{"name": "backbone", "seq": _ydna(rng, 200)},
{"name": "insert", "seq": _ydna(rng, 200)}]
r = C.yeast_assembly(frags, circular=True)
assert r["marker"] is None
assert any("selectable marker" in w for w in r["warnings"])
r2 = C.yeast_assembly(frags, circular=True, marker="HIS3")
assert r2["marker"] == "HIS3"
assert not any("selectable marker" in w for w in r2["warnings"])
# ── Yeast chromosomal integration (knock-in / knock-out) ────────────────────
def test_yeast_integration_designs_cassette_arms_and_verification():
rng = _random.Random(21)
up = _ydna(rng, 300) # genomic sequence 5' of the site
dn = _ydna(rng, 300) # genomic sequence 3' of the site
cassette = _ydna(rng, 1400) # a marker cassette to drop in
r = C.yeast_integration(up, dn, cassette, marker="KanMX")
assert r["ok"] and r["method"] == "yeast_integration" and r["topology"] == "linear"
# the integrated allele is flank + cassette + flank, nothing lost
assert r["assembled"] == up + cassette + dn
assert r["length"] == len(up) + len(cassette) + len(dn)
# the cassette is PCR'd with genomic-homology tails: fwd carries up's 3' arm
p = r["cassette_primers"][0]
assert p["forward_tail"] == r["arm"] == C.YEAST_ARM
assert p["forward"].startswith(up[-C.YEAST_ARM:])
assert p["reverse"].startswith(C.revcomp(dn[:C.YEAST_ARM]))
# two designed junctions, both unique for distinct random flanks
assert len(r["junctions"]) == 2
assert all(j["kind"] == "designed" and j["unique"] for j in r["junctions"])
assert r["marker"] == "KanMX"
assert set(r["plan"]) == {"transform", "selection", "verify"}
def test_yeast_integration_verification_primer_sits_outside_the_arm():
"""On-target confirmation: the 5' check primer anneals in the flanking
genome OUTSIDE the homology arm, and the 3' check primer OUTSIDE the 3'
arm. A primer inside the arm (or inside the cassette) would give a band at
any integration site and prove nothing."""
rng = _random.Random(22)
up, dn, cassette = _ydna(rng, 300), _ydna(rng, 300), _ydna(rng, 1200)
r = C.yeast_integration(up, dn, cassette, marker="HIS3")
v5, v3 = r["verification"]
arm = r["arm"]
# the 5' forward primer is sourced from genome 5' of the arm
assert v5["forward"] and v5["forward"] in up[:-arm]
assert v5["forward"] not in up[-arm:]
# the 3' reverse primer anneals to genome 3' of the arm (bottom strand)
assert v3["reverse"] and C.revcomp(v3["reverse"]) in dn[arm:]
assert "OUTSIDE" in v5["note"] and "OUTSIDE" in v3["note"]
def test_yeast_integration_flags_arms_that_share_homology():
rng = _random.Random(23)
shared = _ydna(rng, 26) # >= 20 bp present in BOTH arms
up = _ydna(rng, 220) + shared # up[-40:] ends with `shared`
dn = shared + _ydna(rng, 220) # dn[:40] starts with `shared`
r = C.yeast_integration(up, dn, _ydna(rng, 800), marker="URA3")
assert r["ok"]
assert all(j["unique"] is False for j in r["junctions"])
assert any("share sequence" in w for w in r["warnings"])
def test_yeast_integration_always_says_to_blast_the_arms():
"""The engine cannot see the genome, so off-target risk is never silently
cleared β€” it is always surfaced as a thing the user must check."""
rng = _random.Random(24)
r = C.yeast_integration(_ydna(rng, 250), _ydna(rng, 250), _ydna(rng, 900), marker="LEU2")
assert any("BLAST" in w for w in r["warnings"])
def test_yeast_integration_rejects_flanks_shorter_than_the_arm():
rng = _random.Random(25)
r = C.yeast_integration(_ydna(rng, 20), _ydna(rng, 250), _ydna(rng, 400))
assert r["ok"] is False
assert "homology arm" in r["error"]
def test_yeast_integration_warns_when_no_marker_named():
rng = _random.Random(26)
r = C.yeast_integration(_ydna(rng, 250), _ydna(rng, 250), _ydna(rng, 400))
assert r["marker"] is None
assert any("marker" in w for w in r["warnings"])