File size: 7,762 Bytes
dd2d535
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Base-editing design (dee/core/base_editor.py).

Pins the editor library, sequence-only edit prediction (incl. bystander
detection), and β€” most importantly β€” the frame-aware codon consequence,
which is what tells a customer "this guide installs a premature stop".
A wrong AA call here would be as damaging as a wrong off-target, so the
codon math is tested against hand-verified examples and is required to
return None rather than guess when the alignment doesn't check out.
"""
import pytest

from dee.core import base_editor as BE


# ─────────────────────────── editor library ───────────────────────────

def test_library_shape():
    be4 = BE.get_base_editor("be4max")
    assert be4.kind == "CBE" and be4.target_base == "C" and be4.result_base == "T"
    assert be4.window == (4, 8)
    abe = BE.get_base_editor("abe7.10")
    assert abe.kind == "ABE" and abe.target_base == "A" and abe.result_base == "G"
    assert BE.get_base_editor("nope") is None


def test_list_filters_by_kind():
    assert {e.id for e in BE.list_base_editors("CBE")} == {"be4max", "be3", "evocda_be4max"}
    assert {e.id for e in BE.list_base_editors("ABE")} == {"abe8e", "abe7.10"}


def test_activity_profiles_bounded_and_peaked():
    for ed in BE.list_base_editors():
        lo, hi = ed.window
        assert set(ed.activity) == set(range(lo, hi + 1))
        assert all(0.0 <= v <= 1.0 for v in ed.activity.values())
        assert max(ed.activity.values()) == pytest.approx(1.0)


# ─────────────────────────── edit prediction ───────────────────────────

def test_single_cbe_edit_in_window():
    spacer = "AAAC" + "A" * 16          # C at position 4 only
    p = BE.predict_base_edits(spacer, "be4max")
    assert p.n_targets == 1
    assert p.edits[0][:3] == (4, "C", "T")
    assert p.editability == pytest.approx(0.35)   # window edge (pos 4)
    assert p.has_bystander is False
    assert p.summary == "C4β†’T"


def test_bystander_flagged_and_editability_is_strongest():
    spacer = "AAACA" + "C" + "A" * 14    # C at positions 4 and 6
    p = BE.predict_base_edits(spacer, "be4max")
    assert p.n_targets == 2
    assert p.has_bystander is True
    assert p.editability == pytest.approx(1.0)    # pos 6 is the window peak
    assert "bystander" in p.summary


def test_no_target_in_window():
    p = BE.predict_base_edits("A" * 20, "be4max")
    assert p.n_targets == 0 and p.editability == 0.0
    assert "no C" in p.summary


def test_abe_targets_adenine():
    spacer = "GGGA" + "G" * 16           # A at position 4
    p = BE.predict_base_edits(spacer, "abe7.10")
    assert p.n_targets == 1
    assert p.edits[0][:3] == (4, "A", "G")


def test_unknown_editor_returns_empty():
    p = BE.predict_base_edits("ACGT" * 5, "not_an_editor")
    assert p.n_targets == 0 and p.edits == []


# ─────────────────────────── codon consequence ───────────────────────────

def test_istop_cga_to_tga():
    # CGA (Arg) --C→T at codon position 1--> TGA (stop). The CRISPR-STOP move.
    c = BE.codon_consequence("CGA" + "GGG", 0, "C", "T")
    assert c.creates_stop is True
    assert c.kind == "nonsense"
    assert (c.aa_before, c.aa_after) == ("R", "*")
    assert c.label == "R1* (stop gained)"


@pytest.mark.parametrize("codon,aa", [("CAA", "Q"), ("CAG", "Q"), ("CGA", "R")])
def test_all_cbe_istop_codons(codon, aa):
    # The three codons CBE can turn into a stop by a single C→T.
    c = BE.codon_consequence(codon + "TTT", 0, "C", "T")
    assert c.creates_stop and c.aa_before == aa and c.aa_after == "*"


def test_silent_edit():
    # GCT (Ala) --T→C at position 3--> GCC (still Ala).
    c = BE.codon_consequence("GCT" + "AAA", 2, "T", "C")
    assert c.kind == "silent" and c.aa_before == c.aa_after == "A"
    assert "silent" in c.label


def test_missense_edit():
    # GCT (Ala) --G→A at position 1--> ACT (Thr).
    c = BE.codon_consequence("GCT" + "AAA", 0, "G", "A")
    assert c.kind == "missense" and (c.aa_before, c.aa_after) == ("A", "T")


def test_stop_loss_edit():
    # TGA (stop) --A→G at position 3--> TGG (Trp).
    c = BE.codon_consequence("TGA" + "GCT", 2, "A", "G")
    assert c.kind == "stop_loss" and c.creates_stop is False
    assert (c.aa_before, c.aa_after) == ("*", "W")


def test_residue_numbering_uses_codon_index():
    # Second codon β†’ residue 2.
    c = BE.codon_consequence("GGG" + "CGA", 3, "C", "T")
    assert c.label == "R2* (stop gained)"


def test_refuses_to_guess_on_mismatch_or_out_of_range():
    # ref_base disagrees with the CDS β†’ None (no fabricated consequence).
    assert BE.codon_consequence("GCT", 0, "A", "T") is None
    # index past the end β†’ None.
    assert BE.codon_consequence("GCT", 99, "T", "C") is None
    # incomplete trailing codon β†’ None.
    assert BE.codon_consequence("GC", 0, "G", "A") is None


# ─────────────── integration: find_guides(mode="base_edit") ───────────────
# Locks the full path β€” edit prediction + coordinate mapping (both strands)
# + frame auto-detection + codon translation β€” against a fixed in-frame CDS.

from dee.core import crispr as C  # noqa: E402

_CDS = "".join([
    "ATG", "GCT", "CGA", "GAT", "CAG", "GGC", "CGT", "CAA", "GCC", "TGG",
    "CGC", "GAA", "CGG", "CTG", "CGA", "GCA", "CAG", "GAT", "CGT", "TAA",
])  # 60 nt, starts ATG, ends TAA, no internal stop β†’ auto-detected frame


def test_base_edit_mode_populates_fields_and_ranks_by_editability():
    guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
                           mode="base_edit", base_editor="be4max")
    assert guides
    for g in guides:
        assert g.be_editor == "be4max"
        assert 0.0 <= g.be_editability <= 1.0
    eds = [g.be_editability for g in guides]
    assert eds == sorted(eds, reverse=True)        # editability-first ranking


def test_base_edit_detects_istop_on_forward_strand():
    guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
                           mode="base_edit", base_editor="be4max")
    stops = {g.be_aa_change for g in guides if g.be_creates_stop}
    # CAG (Q) → TAG (*) via a C→T in the window, at residues 5 and 8.
    assert "Q5* (stop gained)" in stops
    assert "Q8* (stop gained)" in stops
    for g in guides:
        if g.be_creates_stop:
            assert g.be_aa_change.endswith("* (stop gained)")


def test_base_edit_reverse_strand_aa_mapping():
    # A '-' strand guide editing the coding-strand G→A: validates the
    # reverse-strand coordinate + complement transform end-to-end.
    guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
                           mode="base_edit", base_editor="be4max")
    changes = {g.be_aa_change for g in guides if g.strand == "-"}
    assert "E12K" in changes


def test_base_edit_flags_bystander():
    guides = C.find_guides(_CDS, enzyme="cas9", max_results=200,
                           mode="base_edit", base_editor="be4max")
    assert any(g.be_has_bystander for g in guides)


def test_no_aa_change_without_frame():
    # A random (non-CDS) sequence β†’ edits predicted, but no AA call.
    import random
    rng = random.Random(11)
    seq = "".join(rng.choice("ACGT") for _ in range(400))
    guides = C.find_guides(seq, enzyme="cas9", max_results=50,
                           mode="base_edit", base_editor="be4max")
    assert guides
    assert all(g.be_aa_change == "" for g in guides)   # frame unknown β†’ no guess