syntheogenesis / tests /test_offtarget.py
Tengo Gzirishvili
CRISPR M0: validation test suite + fix KO cut-position copy-paste bug
36858f4
Raw
History Blame Contribute Delete
3.74 kB
"""Genome off-target search — pure-function + injected-index coverage.
NO NETWORK. We never hit NCBI/Ensembl here: the kmer-index search logic
is exercised by injecting a synthetic in-memory index, and the index
*builder* is exercised against a tiny temp FASTA on disk.
"""
import pytest
from dee.core import offtarget as OT
def _rc(s: str) -> str:
return s.translate(str.maketrans("ACGT", "TGCA"))[::-1]
@pytest.fixture
def restore_kmer_cache():
"""Snapshot + restore the module-level cache so injected test
indexes never leak into other tests."""
snapshot = dict(OT._KMER_CACHE)
try:
yield
finally:
OT._KMER_CACHE.clear()
OT._KMER_CACHE.update(snapshot)
def test_revcomp():
assert OT._revcomp("ACGT") == "ACGT"
assert OT._revcomp("ATGC") == "GCAT"
def test_cfd_score_delegates_to_crispr_matrix():
assert OT._cfd_score("A" * 20, "A" * 20, "TGG") == 1.0
assert OT._cfd_score("A" * 20, "A" * 20, "AAA") == 0.0
def test_organism_readiness_and_status():
assert OT.is_organism_ready("ecoli") is True
assert OT.is_organism_ready("human") is True
assert OT.is_organism_ready("frog") is False
assert OT.index_status("") == "n/a"
assert OT.index_status("frog") == "unavailable"
def test_index_status_building_vs_ready(restore_kmer_cache):
OT._KMER_CACHE.pop("ecoli", None)
assert OT.index_status("ecoli") == "building"
OT._KMER_CACHE["ecoli"] = OT.KmerIndex("ecoli", 1, 0, {})
assert OT.index_status("ecoli") == "ready"
TARGET = "ACGTACGTACGTACGTACGT" # 20 nt; seed (last 8) = "ACGTACGT"
def _inject_index(spacer, pam="TGG", chrom="chrTest", pos=100, strand="+"):
seed = spacer[-OT._SEED_LEN:]
return OT.KmerIndex(
organism="ecoli", n_chroms=1, n_sites=1,
by_seed={seed: [(spacer, pam, chrom, pos, strand)]},
)
def test_perfect_match_offtarget_scores_one(restore_kmer_cache):
OT._KMER_CACHE["ecoli"] = _inject_index(TARGET)
hits = OT.find_genomic_offtargets(TARGET, "ecoli")
assert len(hits) == 1
assert hits[0].cfd == pytest.approx(1.0)
assert hits[0].n_mismatches == 0
assert hits[0].position_1 == 100
def test_single_distal_mismatch_still_found(restore_kmer_cache):
OT._KMER_CACHE["ecoli"] = _inject_index(TARGET)
guide = "C" + TARGET[1:] # mismatch at position 1 (distal)
hits = OT.find_genomic_offtargets(guide, "ecoli")
assert len(hits) == 1
assert hits[0].n_mismatches == 1
assert 0.0 < hits[0].cfd < 1.0
def test_many_seed_mismatches_not_found(restore_kmer_cache):
OT._KMER_CACHE["ecoli"] = _inject_index(TARGET)
# Change 3 bases inside the seed (last 8) → seed bucket miss
# (search only covers ≤1 seed mismatch).
guide = TARGET[:12] + _rc(TARGET[12:15]) + TARGET[15:]
hits = OT.find_genomic_offtargets(guide, "ecoli")
assert hits == []
def test_non_20nt_guide_returns_empty(restore_kmer_cache):
OT._KMER_CACHE["ecoli"] = _inject_index(TARGET)
assert OT.find_genomic_offtargets("ACGT", "ecoli") == []
def test_unsupported_organism_returns_empty():
assert OT.find_genomic_offtargets(TARGET, "frog") == []
def test_build_kmer_index_from_temp_fasta(tmp_path, restore_kmer_cache):
spacer = "ACGTACGTACGTACGTACGT"
seq = spacer + "AGG" + "TTTTTTTT" # forward NGG ("AGG") right after a 20-nt spacer
fa = tmp_path / "tiny.fa"
fa.write_text(">chr1\n" + seq + "\n")
idx = OT._build_kmer_index("test", str(fa))
assert idx.n_sites >= 1
seed = spacer[-OT._SEED_LEN:]
assert seed in idx.by_seed
fwd = [e for bucket in idx.by_seed.values() for e in bucket if e[4] == "+"]
assert any(e[0] == spacer and e[1] == "AGG" and e[3] == 1 for e in fwd)