use axum::{extract::State, http::StatusCode, response::Json};
use std::time::Instant;
use crate::effect::SpliceRegionBin;
use crate::hgvs::location::CdsPos;
use crate::service::{
server::AppState,
types::{
EffectRequest, EffectResponse, ErrorResponse, NmdPrediction, ProteinConsequence,
SequenceEffect, ServiceError,
},
validation::validate_hgvs,
};
pub async fn predict_effect(
State(state): State<AppState>,
Json(request): Json<EffectRequest>,
) -> Result<Json<EffectResponse>, (StatusCode, Json<ErrorResponse>)> {
let start = Instant::now();
if let Err(validation_error) = validate_hgvs(&request.hgvs) {
let error = ServiceError::InvalidHgvs(validation_error.to_string());
return Err((
StatusCode::from_u16(error.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
Json(error.to_response()),
));
}
let hgvs_str = request.hgvs.clone();
let parse_result =
tokio::task::spawn_blocking(move || crate::hgvs::parser::parse_hgvs_lenient(&hgvs_str))
.await
.map_err(|e| {
let error = ServiceError::InternalError(format!("Task error: {}", e));
(StatusCode::INTERNAL_SERVER_ERROR, Json(error.to_response()))
})?;
let elapsed_ms = start.elapsed().as_millis() as u64;
match parse_result {
Ok(result) => {
let (effect, protein_consequence, nmd_prediction) =
predict_from_variant(&state, &result.result, request.include_nmd);
Ok(Json(EffectResponse {
input: request.hgvs,
effect,
protein_consequence,
nmd_prediction,
error: None,
processing_time_ms: elapsed_ms,
}))
}
Err(e) => Ok(Json(EffectResponse {
input: request.hgvs,
effect: None,
protein_consequence: None,
nmd_prediction: None,
error: Some(format!("Failed to parse input: {}", e)),
processing_time_ms: elapsed_ms,
})),
}
}
fn predict_from_variant(
state: &AppState,
variant: &crate::hgvs::variant::HgvsVariant,
include_nmd: bool,
) -> (
Option<SequenceEffect>,
Option<ProteinConsequence>,
Option<NmdPrediction>,
) {
use crate::hgvs::variant::HgvsVariant;
match variant {
HgvsVariant::Cds(v) => {
let accession = v.accession.to_string();
let cds_pos = extract_cds_position(&v.loc_edit);
let span_len = span_len_from_cds_interval(&v.loc_edit.location);
let edit_opt = v.loc_edit.edit.inner();
let (edit_type, is_frameshift, ref_len, alt_len) = match edit_opt {
Some(edit) => analyze_na_edit(edit, span_len),
None => ("unknown", false, 0, 0),
};
let is_intronic = cds_pos.is_intronic();
let coding = !is_intronic && !cds_pos.utr3 && cds_pos.base > 0;
let span_undecidable = matches!(edit_type, "deletion" | "duplication" | "insertion")
&& ref_len == 0
&& alt_len == 0;
let effect = if coding && span_undecidable {
SequenceEffect {
so_term: "SO:0001580".to_string(),
name: "coding_sequence_variant".to_string(),
description: "A coding-sequence change whose span/length is undecidable"
.to_string(),
impact: "MODERATE".to_string(),
}
} else {
predict_cds_effect(edit_type, is_intronic, is_frameshift, &cds_pos)
};
let protein_consequence = if coding && !span_undecidable {
predict_protein_consequence(
state,
&accession,
&cds_pos,
edit_opt,
edit_type,
is_frameshift,
ref_len,
)
} else {
None
};
let frame_undecidable =
edit_opt.is_some_and(|edit| delins_frame_undecidable(edit, span_len));
let nmd_prediction = if include_nmd && !span_undecidable {
predict_nmd_for_cds(
state,
&accession,
&cds_pos,
edit_opt,
is_frameshift,
edit_type,
frame_undecidable,
)
} else {
None
};
(Some(effect), protein_consequence, nmd_prediction)
}
HgvsVariant::Genome(v) => {
if let Some(edit) = v.loc_edit.edit.inner() {
let (edit_type, _, _, _) = analyze_na_edit(edit, None);
let effect = predict_genomic_effect(edit_type);
(Some(effect), None, None)
} else {
(None, None, None)
}
}
HgvsVariant::Protein(v) => {
let effect = SequenceEffect {
so_term: "SO:0001583".to_string(),
name: "missense_variant".to_string(),
description: "A sequence variant that changes one or more bases".to_string(),
impact: "MODERATE".to_string(),
};
let protein = ProteinConsequence {
hgvs_p: format!("{}", v),
ref_aa: "".to_string(),
alt_aa: "".to_string(),
position: 0,
is_frameshift: false,
};
(Some(effect), Some(protein), None)
}
HgvsVariant::Tx(v) => {
if let Some(edit) = v.loc_edit.edit.inner() {
let (edit_type, _, _, _) = analyze_na_edit(edit, None);
let effect = SequenceEffect {
so_term: "SO:0001619".to_string(),
name: "non_coding_transcript_variant".to_string(),
description: format!("A {} in a non-coding transcript", edit_type),
impact: "MODIFIER".to_string(),
};
(Some(effect), None, None)
} else {
(None, None, None)
}
}
_ => (None, None, None),
}
}
fn extract_cds_position(
loc_edit: &crate::hgvs::variant::LocEdit<
crate::hgvs::interval::CdsInterval,
crate::hgvs::edit::NaEdit,
>,
) -> CdsPos {
let interval = &loc_edit.location;
if let Some(start) = interval.start.inner() {
CdsPos {
base: start.base,
offset: start.offset,
utr3: start.utr3,
special: start.special,
}
} else {
CdsPos::new(1)
}
}
pub fn analyze_na_edit(
edit: &crate::hgvs::edit::NaEdit,
span_len: Option<usize>,
) -> (&'static str, bool, usize, usize) {
use crate::hgvs::edit::NaEdit;
match edit {
NaEdit::Substitution {
reference,
alternative,
} => (
"substitution",
false,
reference.to_string().len(),
alternative.to_string().len(),
),
NaEdit::SubstitutionNoRef { alternative } => {
("substitution", false, 1, alternative.to_string().len())
}
NaEdit::Deletion { sequence, length } => {
let len_opt = sequence
.as_ref()
.map(|s| s.to_string().len())
.or(length.map(|l| l as usize))
.or(span_len);
match len_opt {
Some(len) => ("deletion", len % 3 != 0, len, 0),
None => ("deletion", false, 0, 0),
}
}
NaEdit::Insertion { sequence } => {
match sequence.len() {
Some(len) => ("insertion", len % 3 != 0, 0, len),
None => ("insertion", false, 0, 0),
}
}
NaEdit::Delins { sequence, .. } => {
let alt_len_opt = sequence.len();
let (ref_len, alt_len, is_frameshift) = match (span_len, alt_len_opt) {
(Some(rl), Some(al)) => {
let net_delta = al as isize - rl as isize;
(rl, al, net_delta.rem_euclid(3) != 0)
}
(Some(rl), None) => (rl, 0, false),
(None, Some(al)) => (0, al, false),
(None, None) => (0, 0, false),
};
("delins", is_frameshift, ref_len, alt_len)
}
NaEdit::Duplication {
sequence, length, ..
} => {
let len_opt = sequence
.as_ref()
.map(|s| s.to_string().len())
.or(length.map(|l| l as usize))
.or(span_len);
match len_opt {
Some(len) => ("duplication", len % 3 != 0, len, len * 2),
None => ("duplication", false, 0, 0),
}
}
NaEdit::Inversion { sequence, length } => {
let len_opt = sequence
.as_ref()
.map(|s| s.to_string().len())
.or(length.map(|l| l as usize))
.or(span_len);
match len_opt {
Some(len) => ("inversion", false, len, len),
None => ("inversion", false, 0, 0),
}
}
NaEdit::Repeat { .. } => ("repeat", false, 0, 0),
NaEdit::Identity { .. } => ("identity", false, 0, 0),
NaEdit::Unknown { .. } => ("unknown", false, 0, 0),
_ => ("other", false, 0, 0),
}
}
fn delins_frame_undecidable(edit: &crate::hgvs::edit::NaEdit, span_len: Option<usize>) -> bool {
match edit {
crate::hgvs::edit::NaEdit::Delins { sequence, .. } => {
span_len.is_none() || sequence.len().is_none()
}
_ => false,
}
}
pub fn span_len_from_cds_interval(interval: &crate::hgvs::interval::CdsInterval) -> Option<usize> {
let start = interval.start.inner()?;
let end = interval.end.inner()?;
if start.is_unknown() || start.is_special() || end.is_unknown() || end.is_special() {
return None;
}
if start.offset.is_some() || end.offset.is_some() {
return None;
}
if start.utr3 != end.utr3 {
return None;
}
if (start.base < 0) != (end.base < 0) {
return None;
}
if end.base < start.base {
return None;
}
Some(((end.base - start.base) + 1) as usize)
}
pub fn span_len_from_genome_interval(
interval: &crate::hgvs::interval::GenomeInterval,
) -> Option<usize> {
let start = interval.start.inner()?;
let end = interval.end.inner()?;
if start.offset.is_some() || end.offset.is_some() {
return None;
}
if start.is_special() || end.is_special() {
return None;
}
if end.base < start.base {
return None;
}
Some(((end.base - start.base) + 1) as usize)
}
pub fn span_len_from_tx_interval(interval: &crate::hgvs::interval::TxInterval) -> Option<usize> {
let start = interval.start.inner()?;
let end = interval.end.inner()?;
if start.offset.is_some() || end.offset.is_some() {
return None;
}
if start.downstream || end.downstream {
return None;
}
if start.base == 0 || end.base == 0 {
return None;
}
if (start.base < 0) != (end.base < 0) {
return None;
}
if end.base < start.base {
return None;
}
Some(((end.base - start.base) + 1) as usize)
}
fn predict_cds_effect(
edit_type: &str,
is_intronic: bool,
is_frameshift: bool,
cds_pos: &CdsPos,
) -> SequenceEffect {
if is_intronic {
if let Some(offset) = cds_pos.offset {
match SpliceRegionBin::from_offset(offset) {
SpliceRegionBin::Canonical => {
return SequenceEffect {
so_term: "SO:0001629".to_string(),
name: "splice_site_variant".to_string(),
description: "A sequence variant that changes the first two or last two bases of an intron".to_string(),
impact: "HIGH".to_string(),
};
}
SpliceRegionBin::Region => {
return SequenceEffect {
so_term: "SO:0001630".to_string(),
name: "splice_region_variant".to_string(),
description: "A sequence variant in which a change has occurred within the region of the splice site".to_string(),
impact: "LOW".to_string(),
};
}
SpliceRegionBin::Intron => {}
}
}
return SequenceEffect {
so_term: "SO:0001627".to_string(),
name: "intron_variant".to_string(),
description: "A transcript variant occurring within an intron".to_string(),
impact: "MODIFIER".to_string(),
};
}
if cds_pos.base < 0 {
return SequenceEffect {
so_term: "SO:0001623".to_string(),
name: "5_prime_UTR_variant".to_string(),
description: "A UTR variant of the 5' UTR".to_string(),
impact: "MODIFIER".to_string(),
};
}
if cds_pos.utr3 {
return SequenceEffect {
so_term: "SO:0001624".to_string(),
name: "3_prime_UTR_variant".to_string(),
description: "A UTR variant of the 3' UTR".to_string(),
impact: "MODIFIER".to_string(),
};
}
match edit_type {
"substitution" => SequenceEffect {
so_term: "SO:0001583".to_string(),
name: "missense_variant".to_string(),
description: "A codon change resulting in a different amino acid (predicted)"
.to_string(),
impact: "MODERATE".to_string(),
},
"deletion" => {
if is_frameshift {
SequenceEffect {
so_term: "SO:0001589".to_string(),
name: "frameshift_variant".to_string(),
description:
"A sequence variant which causes a disruption of the translational reading frame"
.to_string(),
impact: "HIGH".to_string(),
}
} else {
SequenceEffect {
so_term: "SO:0001822".to_string(),
name: "inframe_deletion".to_string(),
description:
"An inframe non-synonymous variant that deletes bases from the coding sequence"
.to_string(),
impact: "MODERATE".to_string(),
}
}
}
"insertion" => {
if is_frameshift {
SequenceEffect {
so_term: "SO:0001589".to_string(),
name: "frameshift_variant".to_string(),
description:
"A sequence variant which causes a disruption of the translational reading frame"
.to_string(),
impact: "HIGH".to_string(),
}
} else {
SequenceEffect {
so_term: "SO:0001821".to_string(),
name: "inframe_insertion".to_string(),
description:
"An inframe non-synonymous variant that inserts bases into the coding sequence"
.to_string(),
impact: "MODERATE".to_string(),
}
}
}
"delins" => SequenceEffect {
so_term: "SO:1000032".to_string(),
name: "indel".to_string(),
description: "A sequence alteration which includes both deletion and insertion"
.to_string(),
impact: "MODERATE".to_string(),
},
"duplication" => {
if is_frameshift {
SequenceEffect {
so_term: "SO:0001589".to_string(),
name: "frameshift_variant".to_string(),
description:
"A sequence variant which causes a disruption of the translational reading frame"
.to_string(),
impact: "HIGH".to_string(),
}
} else {
SequenceEffect {
so_term: "SO:1000035".to_string(),
name: "duplication".to_string(),
description:
"An insertion which derives from a copy of a sequence immediately adjacent"
.to_string(),
impact: "MODERATE".to_string(),
}
}
}
"inversion" => SequenceEffect {
so_term: "SO:1000036".to_string(),
name: "inversion".to_string(),
description: "A continuous nucleotide sequence is inverted in the same position"
.to_string(),
impact: "HIGH".to_string(),
},
_ => SequenceEffect {
so_term: "SO:0001580".to_string(),
name: "coding_sequence_variant".to_string(),
description: "A sequence variant that changes the coding sequence".to_string(),
impact: "MODERATE".to_string(),
},
}
}
fn predict_genomic_effect(edit_type: &str) -> SequenceEffect {
match edit_type {
"substitution" => SequenceEffect {
so_term: "SO:0001483".to_string(),
name: "SNV".to_string(),
description: "A single nucleotide variant".to_string(),
impact: "MODIFIER".to_string(),
},
"deletion" => SequenceEffect {
so_term: "SO:0000159".to_string(),
name: "deletion".to_string(),
description: "A sequence alteration where nucleotides are removed".to_string(),
impact: "MODIFIER".to_string(),
},
"insertion" => SequenceEffect {
so_term: "SO:0000667".to_string(),
name: "insertion".to_string(),
description: "The sequence of one or more nucleotides added".to_string(),
impact: "MODIFIER".to_string(),
},
_ => SequenceEffect {
so_term: "SO:0001060".to_string(),
name: "sequence_variant".to_string(),
description: "A sequence variant".to_string(),
impact: "MODIFIER".to_string(),
},
}
}
fn predict_protein_consequence(
state: &AppState,
accession: &str,
cds_pos: &CdsPos,
edit: Option<&crate::hgvs::edit::NaEdit>,
edit_type: &str,
is_frameshift: bool,
ref_len: usize,
) -> Option<ProteinConsequence> {
let cdot = state.cdot.as_ref()?;
let cdot_tx = cdot.get_transcript(accession)?;
let prot_position = if cds_pos.base > 0 {
((cds_pos.base - 1) / 3 + 1) as u64
} else {
return None; };
let codon_phase = ((cds_pos.base - 1) % 3) as u8;
let prot_acc = cdot_tx
.protein
.clone()
.unwrap_or_else(|| accession.to_string());
if let Some(resolved) = resolve_residues(
state,
accession,
&prot_acc,
cds_pos,
edit,
edit_type,
is_frameshift,
ref_len,
) {
return Some(resolved);
}
let is_frameshift = is_frameshift && edit_type != "substitution";
let hgvs_p = if is_frameshift {
format!("{}:p.(?{}fs)", prot_acc, prot_position)
} else if edit_type == "substitution" {
format!("{}:p.(?{}?)", prot_acc, prot_position)
} else if edit_type == "deletion" {
let end_pos = prot_position + (ref_len / 3).max(1) as u64 - 1;
if prot_position == end_pos {
format!("{}:p.(?{}del)", prot_acc, prot_position)
} else {
format!("{}:p.(?{}_?{}del)", prot_acc, prot_position, end_pos)
}
} else if edit_type == "insertion" {
format!(
"{}:p.(?{}_?{}ins?)",
prot_acc,
prot_position,
prot_position + 1
)
} else {
format!("{}:p.(?{}?)", prot_acc, prot_position)
};
Some(ProteinConsequence {
hgvs_p,
ref_aa: format!("?(pos{})", codon_phase + 1), alt_aa: "?".to_string(),
position: prot_position,
is_frameshift,
})
}
#[allow(clippy::too_many_arguments)]
fn resolve_residues(
state: &AppState,
accession: &str,
prot_acc: &str,
cds_pos: &CdsPos,
edit: Option<&crate::hgvs::edit::NaEdit>,
edit_type: &str,
is_frameshift: bool,
ref_len: usize,
) -> Option<ProteinConsequence> {
if cds_pos.base <= 0 || cds_pos.utr3 || cds_pos.offset.is_some() {
return None;
}
let provider = state.reference.as_ref()?;
let transcript =
crate::reference::provider::ReferenceProvider::get_transcript(provider.as_ref(), accession)
.ok()?;
resolve_residues_from_transcript(
&transcript,
prot_acc,
cds_pos,
edit,
edit_type,
is_frameshift,
ref_len,
)
}
fn resolve_residues_from_transcript(
transcript: &crate::reference::transcript::Transcript,
prot_acc: &str,
cds_pos: &CdsPos,
edit: Option<&crate::hgvs::edit::NaEdit>,
edit_type: &str,
is_frameshift: bool,
ref_len: usize,
) -> Option<ProteinConsequence> {
use crate::project::protein::{predict_substitution_protein, read_ref_codon, translate};
let cds_base = cds_pos.base; let prot_position = ((cds_base - 1) / 3 + 1) as u64;
let (ref_codon, frame) = read_ref_codon(transcript, cds_base).ok()?;
let ref_aa = translate(&ref_codon)?;
if edit_type == "substitution" {
let normalized_sub = match edit {
Some(sub @ crate::hgvs::edit::NaEdit::Substitution { .. }) => Some(sub.clone()),
Some(crate::hgvs::edit::NaEdit::SubstitutionNoRef { alternative }) => {
let ref_base = crate::hgvs::edit::Base::from_char(
ref_codon.as_bytes()[frame as usize] as char,
)?;
Some(crate::hgvs::edit::NaEdit::Substitution {
reference: ref_base,
alternative: *alternative,
})
}
_ => None,
};
if let Some(sub) = normalized_sub.as_ref() {
match predict_substitution_protein(transcript, cds_base, sub, prot_acc) {
Ok(variant) => {
let (ref_aa_str, alt_aa, position) =
substitution_residues_from_variant(&variant, ref_aa, prot_position);
return Some(ProteinConsequence {
hgvs_p: format!("{}", variant),
ref_aa: ref_aa_str,
alt_aa,
position,
is_frameshift: false,
});
}
Err(_) => {
return Some(ProteinConsequence {
hgvs_p: format!("{}:p.(?{}?)", prot_acc, prot_position),
ref_aa: ref_aa.to_three_letter().to_string(),
alt_aa: "?".to_string(),
position: prot_position,
is_frameshift: false,
});
}
}
}
}
let hgvs_p = build_indel_hgvs_p(prot_acc, edit_type, prot_position, is_frameshift, ref_len);
Some(ProteinConsequence {
hgvs_p,
ref_aa: ref_aa.to_three_letter().to_string(),
alt_aa: "?".to_string(),
position: prot_position,
is_frameshift,
})
}
fn substitution_residues_from_variant(
variant: &crate::hgvs::variant::HgvsVariant,
raw_ref_aa: crate::hgvs::location::AminoAcid,
raw_position: u64,
) -> (String, String, u64) {
use crate::hgvs::edit::ProteinEdit;
use crate::hgvs::variant::HgvsVariant;
let raw_ref = raw_ref_aa.to_three_letter().to_string();
let HgvsVariant::Protein(p) = variant else {
return (raw_ref.clone(), raw_ref, raw_position);
};
let (loc_ref_aa, loc_position) = match p.loc_edit.location.start.inner() {
Some(pos) => (pos.aa.to_three_letter().to_string(), pos.number),
None => (raw_ref.clone(), raw_position),
};
match p.loc_edit.edit.inner() {
Some(ProteinEdit::Substitution { alternative, .. }) => (
loc_ref_aa,
alternative.to_three_letter().to_string(),
loc_position,
),
Some(ProteinEdit::Unknown { .. }) => (loc_ref_aa, "?".to_string(), loc_position),
Some(ProteinEdit::Identity { .. }) => (raw_ref.clone(), raw_ref, raw_position),
Some(ProteinEdit::Extension { new_aa, .. }) => {
let alt = new_aa
.map(|aa| aa.to_three_letter().to_string())
.unwrap_or_else(|| "?".to_string());
(loc_ref_aa, alt, loc_position)
}
_ => (loc_ref_aa, "?".to_string(), loc_position),
}
}
fn build_indel_hgvs_p(
prot_acc: &str,
edit_type: &str,
prot_position: u64,
is_frameshift: bool,
ref_len: usize,
) -> String {
if is_frameshift {
format!("{}:p.(?{}fs)", prot_acc, prot_position)
} else {
match edit_type {
"deletion" => {
let end_pos = prot_position + (ref_len / 3).max(1) as u64 - 1;
if prot_position == end_pos {
format!("{}:p.(?{}del)", prot_acc, prot_position)
} else {
format!("{}:p.(?{}_?{}del)", prot_acc, prot_position, end_pos)
}
}
"insertion" => format!(
"{}:p.(?{}_?{}ins?)",
prot_acc,
prot_position,
prot_position + 1
),
_ => format!("{}:p.(?{}?)", prot_acc, prot_position),
}
}
}
const NMD_LAST_JUNCTION_ESCAPE_NT: u64 = 55;
const NMD_CONFIDENCE_STRUCTURAL: f64 = 0.9;
const NMD_CONFIDENCE_EXACT_PTC: f64 = 0.85;
const NMD_CONFIDENCE_FRAMESHIFT_PTC: f64 = 0.7;
fn predict_nmd_for_cds(
state: &AppState,
accession: &str,
cds_pos: &CdsPos,
edit: Option<&crate::hgvs::edit::NaEdit>,
is_frameshift: bool,
edit_type: &str,
frame_undecidable: bool,
) -> Option<NmdPrediction> {
let cdot = state.cdot.as_ref()?;
let cdot_tx = cdot.get_transcript(accession)?;
if cds_pos.base <= 0 || cds_pos.utr3 || cds_pos.offset.is_some() {
return None;
}
let introduces_ptc = if is_frameshift {
Some(true)
} else if edit_type == "substitution" {
is_nonsense_substitution(state, accession, cds_pos, edit)
} else if frame_undecidable {
None
} else {
Some(false)
}?;
nmd_from_junction(cdot_tx, cds_pos, is_frameshift, introduces_ptc)
}
fn nmd_from_junction(
cdot_tx: &crate::data::cdot::CdotTranscript,
cds_pos: &CdsPos,
is_frameshift: bool,
introduces_ptc: bool,
) -> Option<NmdPrediction> {
if !introduces_ptc {
return Some(NmdPrediction {
predicted: false,
confidence: NMD_CONFIDENCE_STRUCTURAL,
reason: "Variant does not introduce a premature termination codon".to_string(),
});
}
let ptc_cds_base = if is_frameshift {
cds_pos.base
} else {
cds_pos.base - (cds_pos.base - 1).rem_euclid(3)
};
let ptc_tx = cdot_tx.cds_to_tx(ptc_cds_base)?;
if cdot_tx.exons.is_empty() {
return None;
}
if cdot_tx.exons.len() == 1 {
return Some(NmdPrediction {
predicted: false,
confidence: NMD_CONFIDENCE_STRUCTURAL,
reason: "Single-exon transcript has no exon-exon junction; PTC cannot trigger NMD"
.to_string(),
});
}
let last_junction_tx = cdot_tx.exons.iter().map(|exon| exon[2]).max()?;
let confidence = if is_frameshift {
NMD_CONFIDENCE_FRAMESHIFT_PTC
} else {
NMD_CONFIDENCE_EXACT_PTC
};
let (predicted, reason) = if ptc_tx >= last_junction_tx {
(false, "PTC in the last exon - escapes NMD".to_string())
} else if last_junction_tx - ptc_tx <= NMD_LAST_JUNCTION_ESCAPE_NT {
(
false,
format!(
"PTC within {} nt upstream of the last exon-exon junction - escapes NMD",
NMD_LAST_JUNCTION_ESCAPE_NT
),
)
} else {
(
true,
format!(
"PTC more than {} nt upstream of the last exon-exon junction - triggers NMD",
NMD_LAST_JUNCTION_ESCAPE_NT
),
)
};
Some(NmdPrediction {
predicted,
confidence,
reason,
})
}
fn is_nonsense_substitution(
state: &AppState,
accession: &str,
cds_pos: &CdsPos,
edit: Option<&crate::hgvs::edit::NaEdit>,
) -> Option<bool> {
use crate::hgvs::edit::NaEdit;
match edit? {
NaEdit::Substitution { .. } | NaEdit::SubstitutionNoRef { .. } => {}
_ => return None,
}
let provider = state.reference.as_ref()?;
let transcript =
crate::reference::provider::ReferenceProvider::get_transcript(provider.as_ref(), accession)
.ok()?;
is_nonsense_substitution_from_transcript(&transcript, accession, cds_pos, edit)
}
fn is_nonsense_substitution_from_transcript(
transcript: &crate::reference::transcript::Transcript,
accession: &str,
cds_pos: &CdsPos,
edit: Option<&crate::hgvs::edit::NaEdit>,
) -> Option<bool> {
use crate::hgvs::edit::{Base, NaEdit, ProteinEdit};
use crate::hgvs::variant::HgvsVariant;
use crate::project::protein::{predict_substitution_protein, read_ref_codon};
let normalized_sub;
let edit = match edit? {
sub @ NaEdit::Substitution { .. } => sub,
NaEdit::SubstitutionNoRef { alternative } => {
let (ref_codon, frame) = read_ref_codon(transcript, cds_pos.base).ok()?;
let reference = Base::from_char(ref_codon.as_bytes()[frame as usize] as char)?;
normalized_sub = NaEdit::Substitution {
reference,
alternative: *alternative,
};
&normalized_sub
}
_ => return None,
};
let prot_acc = transcript
.protein_id
.clone()
.unwrap_or_else(|| accession.to_string());
let variant = predict_substitution_protein(transcript, cds_pos.base, edit, &prot_acc).ok()?;
let HgvsVariant::Protein(p) = variant else {
return Some(false);
};
match p.loc_edit.edit.inner() {
Some(ProteinEdit::Substitution {
reference,
alternative,
}) => Some(
*alternative == crate::hgvs::location::AminoAcid::Ter
&& *reference != crate::hgvs::location::AminoAcid::Ter,
),
_ => Some(false),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::cdot::CdotTranscript;
use crate::hgvs::edit::{Base, NaEdit};
use crate::reference::transcript::{Exon as TxExon, Strand, Transcript};
fn seq_transcript(id: &str, seq: &str, protein_id: Option<&str>) -> Transcript {
Transcript {
id: id.to_string(),
strand: Strand::Plus,
sequence: Some(seq.to_string()),
cds_start: Some(1),
cds_end: Some(seq.len() as u64),
exons: vec![TxExon::new(1, 1, seq.len() as u64)],
protein_id: protein_id.map(|s| s.to_string()),
..Default::default()
}
}
fn seq_transcript_with_cds(
id: &str,
seq: &str,
cds_end: u64,
protein_id: Option<&str>,
) -> Transcript {
Transcript {
id: id.to_string(),
strand: Strand::Plus,
sequence: Some(seq.to_string()),
cds_start: Some(1),
cds_end: Some(cds_end),
exons: vec![TxExon::new(1, 1, seq.len() as u64)],
protein_id: protein_id.map(|s| s.to_string()),
..Default::default()
}
}
fn cdot_with_exons(exon_tx_spans: &[(u64, u64)]) -> CdotTranscript {
let exons: Vec<[u64; 4]> = exon_tx_spans
.iter()
.enumerate()
.map(|(i, &(ts, te))| {
let g = 1000 + (i as u64) * 1000;
[g, g + (te - ts), ts, te]
})
.collect();
let tx_len = exon_tx_spans.last().map(|&(_, te)| te).unwrap_or(0);
CdotTranscript {
cds_start_incomplete: false,
gene_name: None,
contig: "NC_000001.11".to_string(),
strand: Strand::Plus,
exons,
cds_start: Some(0),
cds_end: Some(tx_len),
exon_cigars: Vec::new(),
gene_id: None,
protein: None,
}
}
fn cds(base: i64) -> CdsPos {
CdsPos::new(base)
}
#[test]
fn resolve_real_missense_residues() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::G,
alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(5),
Some(&edit),
"substitution",
false,
1,
)
.expect("residues should resolve");
assert_eq!(pc.ref_aa, "Arg", "real reference residue, not '?'");
assert_eq!(pc.alt_aa, "His", "real alternate residue, not '?'");
assert_eq!(pc.position, 2);
assert!(!pc.ref_aa.contains('?') && !pc.alt_aa.contains('?'));
}
#[test]
fn resolve_indel_with_reference_gives_real_ref_residue_not_silent_question() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Deletion {
sequence: None,
length: None,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(4), Some(&edit),
"deletion",
true,
3,
)
.expect("ref residue should resolve");
assert_eq!(
pc.ref_aa, "Arg",
"indel-with-reference resolves real ref residue"
);
assert_eq!(
pc.alt_aa, "?",
"indel alt residue is deferred to #498 (not-yet-implemented)"
);
}
#[test]
fn resolve_inframe_multicodon_deletion_keeps_ranged_form() {
let seq = "ATGCGTGGGGAATAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Deletion {
sequence: None,
length: None,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(4), Some(&edit),
"deletion",
false,
6, )
.expect("ref residue should resolve");
assert_eq!(pc.ref_aa, "Arg", "real reference residue at codon 2");
assert_eq!(
pc.hgvs_p, "NP_TEST.1:p.(?2_?3del)",
"multi-codon deletion keeps the ranged form, not collapsed p.(?2del)"
);
}
#[test]
fn resolve_returns_none_without_sequence() {
let mut tx = seq_transcript("NM_TEST.1", "ATGCGTGGGTAA", Some("NP_TEST.1"));
tx.sequence = None;
let edit = NaEdit::Substitution {
reference: Base::G,
alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(5),
Some(&edit),
"substitution",
false,
1,
);
assert!(
pc.is_none(),
"no sequence => None => honest 'data unavailable'"
);
}
#[test]
fn resolve_initiation_codon_substitution_residues_match_hgvs_p() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::T,
alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(2),
Some(&edit),
"substitution",
false,
1,
)
.expect("initiation-codon substitution should resolve");
assert_eq!(pc.hgvs_p, "NP_TEST.1:p.(Met1?)");
assert_eq!(pc.ref_aa, "Met", "residue 1 is the initiator Met");
assert_eq!(
pc.alt_aa, "?",
"initiation-codon alt residue is unpredictable, must not contradict Met1?"
);
assert_eq!(pc.position, 1);
}
#[test]
fn resolve_non_atg_initiation_codon_substitution_residues_match_hgvs_p() {
let seq = "CTGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::C,
alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(1),
Some(&edit),
"substitution",
false,
1,
)
.expect("non-ATG initiation-codon substitution should resolve");
assert_eq!(pc.hgvs_p, "NP_TEST.1:p.(Met1?)");
assert_ne!(
pc.ref_aa, "Leu",
"must not report the raw CTG residue that contradicts the initiator output"
);
assert_eq!(
pc.ref_aa, "Met",
"initiator residue 1 is Met per the engine"
);
assert_eq!(pc.alt_aa, "?");
}
#[test]
fn resolve_uses_cdot_protein_accession_when_transcript_lacks_protein_id() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, None); let edit = NaEdit::Substitution {
reference: Base::G,
alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1", &cds(5),
Some(&edit),
"substitution",
false,
1,
)
.expect("residues should resolve");
assert_eq!(
pc.hgvs_p, "NP_TEST.1:p.(Arg2His)",
"uses cdot NP_ accession even though transcript.protein_id is None"
);
assert!(
!pc.hgvs_p.starts_with("NM_"),
"must not fall back to the NM_ transcript accession"
);
let del = NaEdit::Deletion {
sequence: None,
length: None,
};
let pc_del = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(4),
Some(&del),
"deletion",
true,
1,
)
.expect("ref residue should resolve");
assert!(
pc_del.hgvs_p.starts_with("NP_TEST.1:"),
"indel path also uses the cdot NP_ accession, got {:?}",
pc_del.hgvs_p
);
}
#[test]
fn resolve_substitution_no_ref_resolves_real_alt_residue() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::SubstitutionNoRef {
alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(5),
Some(&edit),
"substitution",
false,
1,
)
.expect("no-ref substitution should resolve against sequence");
assert_eq!(pc.ref_aa, "Arg", "ref residue read from the codon");
assert_eq!(pc.alt_aa, "His", "no-ref alt residue resolved, not '?'");
assert_eq!(pc.position, 2);
assert_eq!(
pc.hgvs_p, "NP_TEST.1:p.(Arg2His)",
"no-ref form yields fully-resolved HGVS-p, not p.(?2?)"
);
assert!(
!pc.hgvs_p.contains("?"),
"no deferred '?' when sequence exists"
);
}
#[test]
fn resolve_substitution_explicit_ref_mismatch_still_resolves() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::A, alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(5),
Some(&edit),
"substitution",
false,
1,
)
.expect("explicit-ref-mismatch substitution should still resolve");
assert_eq!(
pc.hgvs_p, "NP_TEST.1:p.(Arg2His)",
"explicit-ref mismatch resolves from sequence, not p.(?2?)"
);
assert!(
!pc.hgvs_p.contains("?"),
"explicit-ref mismatch must not yield a sequence-backed p.(?N?)"
);
assert_eq!(pc.ref_aa, "Arg");
assert_eq!(pc.alt_aa, "His");
}
#[test]
fn resolve_stop_loss_extension_reports_readthrough_residue_not_ter_ter() {
let seq = "ATGAAATAAGGGTAA";
let tx = seq_transcript_with_cds("NM_TEST.1", seq, 9, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::T,
alternative: Base::C,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(7),
Some(&edit),
"substitution",
false,
1,
)
.expect("stop-loss substitution should resolve");
assert_eq!(
pc.hgvs_p, "NP_TEST.1:p.(Ter3GlnextTer2)",
"stop-loss yields a C-terminal extension"
);
assert_eq!(pc.ref_aa, "Ter", "reference residue is the terminator");
assert_eq!(
pc.alt_aa, "Gln",
"alternate is the read-through residue, NOT Ter (no ref==alt)"
);
assert_ne!(
pc.ref_aa, pc.alt_aa,
"a stop-loss is not synonymous: ref_aa must not equal alt_aa"
);
assert_eq!(pc.position, 3, "extension is at the original stop codon");
}
#[test]
fn resolve_synonymous_substitution_reports_equal_ref_and_alt() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::T,
alternative: Base::A,
};
let pc = resolve_residues_from_transcript(
&tx,
"NP_TEST.1",
&cds(6),
Some(&edit),
"substitution",
false,
1,
)
.expect("synonymous substitution should resolve");
assert_eq!(
pc.ref_aa, "Arg",
"synonymous change keeps the reference residue"
);
assert_eq!(pc.alt_aa, "Arg", "synonymous: alt residue equals ref");
assert_eq!(pc.position, 2);
}
#[test]
fn detects_nonsense_substitution() {
let seq = "ATGCAAGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::C,
alternative: Base::T,
};
let is_nonsense =
is_nonsense_substitution_from_transcript(&tx, "NM_TEST.1", &cds(4), Some(&edit));
assert_eq!(is_nonsense, Some(true), "CAA->TAA is a new stop (nonsense)");
}
#[test]
fn missense_is_not_nonsense() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::G,
alternative: Base::A,
};
let is_nonsense =
is_nonsense_substitution_from_transcript(&tx, "NM_TEST.1", &cds(5), Some(&edit));
assert_eq!(
is_nonsense,
Some(false),
"CGT->CAT (His) is missense, not nonsense"
);
}
#[test]
fn nonsense_unknown_without_sequence_stays_none() {
let mut tx = seq_transcript("NM_TEST.1", "ATGCAAGGGTAA", Some("NP_TEST.1"));
tx.sequence = None;
let edit = NaEdit::Substitution {
reference: Base::C,
alternative: Base::T,
};
let is_nonsense =
is_nonsense_substitution_from_transcript(&tx, "NM_TEST.1", &cds(4), Some(&edit));
assert_eq!(
is_nonsense, None,
"no sequence => unknown nonsense state, never a coerced false"
);
}
#[test]
fn detects_nonsense_no_ref_substitution() {
let seq = "ATGCAAGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::SubstitutionNoRef {
alternative: Base::T,
};
let is_nonsense =
is_nonsense_substitution_from_transcript(&tx, "NM_TEST.1", &cds(4), Some(&edit));
assert_eq!(
is_nonsense,
Some(true),
"no-ref c.4N>T (CAA->TAA) is a new stop (nonsense), not dropped"
);
}
#[test]
fn no_ref_missense_substitution_is_not_nonsense() {
let seq = "ATGCGTGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::SubstitutionNoRef {
alternative: Base::A,
};
let is_nonsense =
is_nonsense_substitution_from_transcript(&tx, "NM_TEST.1", &cds(5), Some(&edit));
assert_eq!(
is_nonsense,
Some(false),
"no-ref CGT->CAT (His) is missense, resolved (not unknown)"
);
}
#[test]
fn nmd_ptc_in_last_exon_escapes() {
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let pred = nmd_from_junction(&cdot_tx, &cds(201), true, true).unwrap();
assert!(!pred.predicted, "PTC in last exon escapes NMD");
assert!(pred.reason.contains("last exon"));
}
#[test]
fn nmd_ptc_within_55nt_of_last_junction_escapes() {
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let pred = nmd_from_junction(&cdot_tx, &cds(180), true, true).unwrap();
assert!(!pred.predicted, "PTC within 55 nt of last junction escapes");
}
#[test]
fn nmd_55nt_boundary_is_inclusive() {
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let escape = nmd_from_junction(&cdot_tx, &cds(146), true, true).unwrap();
assert!(
!escape.predicted,
"exactly 55 nt upstream escapes (inclusive)"
);
let trigger = nmd_from_junction(&cdot_tx, &cds(145), true, true).unwrap();
assert!(trigger.predicted, "56 nt upstream triggers NMD");
}
#[test]
fn nmd_ptc_mid_cds_triggers() {
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let pred = nmd_from_junction(&cdot_tx, &cds(10), true, true).unwrap();
assert!(pred.predicted, "PTC far 5' of last junction triggers NMD");
assert!(pred.reason.contains("triggers NMD"));
}
#[test]
fn nmd_single_exon_never_triggers() {
let cdot_tx = cdot_with_exons(&[(0, 250)]);
let pred = nmd_from_junction(&cdot_tx, &cds(10), true, true).unwrap();
assert!(!pred.predicted, "single-exon transcript never triggers NMD");
assert!(pred.reason.contains("Single-exon"));
}
#[test]
fn nmd_empty_exons_is_undecided() {
let cdot_tx = cdot_with_exons(&[]);
assert!(
nmd_from_junction(&cdot_tx, &cds(4), false, true).is_none(),
"empty exon metadata yields no NMD prediction"
);
}
#[test]
fn nmd_last_junction_uses_max_tx_start_not_order() {
let sorted = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let unsorted = cdot_with_exons(&[(200, 250), (0, 100), (100, 200)]);
let from_sorted = nmd_from_junction(&sorted, &cds(50), true, true).unwrap();
let from_unsorted = nmd_from_junction(&unsorted, &cds(50), true, true).unwrap();
assert!(from_sorted.predicted);
assert_eq!(
from_sorted.predicted, from_unsorted.predicted,
"junction derived by max tx-start is order-independent"
);
}
#[test]
fn nmd_nonsense_substitution_mid_cds_triggers_end_to_end() {
let seq = "ATGCAAGGGTAA"; let tx = seq_transcript("NM_TEST.1", seq, Some("NP_TEST.1"));
let edit = NaEdit::Substitution {
reference: Base::C,
alternative: Base::T,
};
let introduces_ptc =
is_nonsense_substitution_from_transcript(&tx, "NM_TEST.1", &cds(4), Some(&edit))
.unwrap_or(false);
assert!(introduces_ptc, "nonsense substitution introduces a PTC");
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let pred = nmd_from_junction(&cdot_tx, &cds(4), false, introduces_ptc).unwrap();
assert!(pred.predicted, "nonsense PTC mid-CDS triggers NMD");
}
#[test]
fn nmd_nonsense_substitution_is_phase_independent_at_boundary() {
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
for base in [145, 146, 147] {
let pred = nmd_from_junction(&cdot_tx, &cds(base), false, true).unwrap();
assert!(
pred.predicted,
"nonsense at c.{base} (codon start c.145, 56 nt upstream) must trigger NMD"
);
}
}
#[test]
fn nmd_frameshift_uses_edited_base_not_codon_start() {
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let pred = nmd_from_junction(&cdot_tx, &cds(146), true, true).unwrap();
assert!(
!pred.predicted,
"frameshift proxy at tx 145 (55 nt upstream) escapes; not codon-normalized"
);
}
#[test]
fn nmd_no_ptc_does_not_trigger() {
let cdot_tx = cdot_with_exons(&[(0, 100), (100, 200), (200, 250)]);
let pred = nmd_from_junction(&cdot_tx, &cds(10), false, false).unwrap();
assert!(
!pred.predicted,
"variant without a PTC does not trigger NMD"
);
assert!(pred.reason.contains("does not introduce"));
}
#[test]
fn test_analyze_substitution() {
let result = crate::hgvs::parser::parse_hgvs_lenient("NM_000249.4:c.350C>T").unwrap();
if let crate::hgvs::variant::HgvsVariant::Cds(v) = &result.result {
if let Some(edit) = v.loc_edit.edit.inner() {
let (edit_type, is_frameshift, _, _) = analyze_na_edit(edit, None);
assert_eq!(edit_type, "substitution");
assert!(!is_frameshift);
}
}
}
#[test]
fn test_analyze_frameshift_deletion() {
let result = crate::hgvs::parser::parse_hgvs_lenient("NM_000249.4:c.350del").unwrap();
if let crate::hgvs::variant::HgvsVariant::Cds(v) = &result.result {
if let Some(edit) = v.loc_edit.edit.inner() {
let span_len = span_len_from_cds_interval(&v.loc_edit.location);
let (edit_type, is_frameshift, _, _) = analyze_na_edit(edit, span_len);
assert_eq!(edit_type, "deletion");
assert!(is_frameshift); }
}
}
#[test]
fn test_analyze_inframe_deletion() {
let result = crate::hgvs::parser::parse_hgvs_lenient("NM_000249.4:c.350_352del").unwrap();
if let crate::hgvs::variant::HgvsVariant::Cds(v) = &result.result {
if let Some(edit) = v.loc_edit.edit.inner() {
let span_len = span_len_from_cds_interval(&v.loc_edit.location);
let (edit_type, is_frameshift, ref_len, alt_len) = analyze_na_edit(edit, span_len);
assert_eq!(edit_type, "deletion");
assert_eq!(ref_len, 3);
assert_eq!(alt_len, 0);
assert!(!is_frameshift, "3 bp deletion is in-frame");
}
}
}
#[test]
fn delins_unknown_alt_len_is_frame_undecidable() {
use crate::hgvs::edit::InsertedSequence;
let edit = NaEdit::Delins {
sequence: InsertedSequence::Range(5, 10),
deleted: None,
deleted_length: None,
substitution_reference: None,
};
assert!(
delins_frame_undecidable(&edit, Some(3)),
"unknown inserted length leaves the net frame delta undecidable"
);
}
#[test]
fn delins_unknown_span_with_literal_is_frame_undecidable() {
use crate::hgvs::edit::{InsertedSequence, Sequence};
let edit = NaEdit::Delins {
sequence: InsertedSequence::Literal(Sequence::new(vec![Base::A])),
deleted: None,
deleted_length: None,
substitution_reference: None,
};
assert!(
delins_frame_undecidable(&edit, None),
"unknown deleted span leaves the net frame delta undecidable"
);
}
#[test]
fn delins_known_span_and_alt_is_frame_decidable() {
use crate::hgvs::edit::{InsertedSequence, Sequence};
let edit = NaEdit::Delins {
sequence: InsertedSequence::Literal(Sequence::new(vec![Base::A, Base::T])),
deleted: None,
deleted_length: None,
substitution_reference: None,
};
assert!(
!delins_frame_undecidable(&edit, Some(3)),
"both ref span and alt length known => frame delta is decidable"
);
}
#[test]
fn non_delins_is_never_frame_undecidable() {
let edit = NaEdit::Deletion {
sequence: None,
length: None,
};
assert!(!delins_frame_undecidable(&edit, None));
assert!(!delins_frame_undecidable(&edit, Some(2)));
}
#[test]
fn test_predict_splice_site_effect() {
let cds_pos = CdsPos {
base: 117,
offset: Some(-2),
utr3: false,
special: None,
};
let effect = predict_cds_effect("deletion", true, false, &cds_pos);
assert_eq!(effect.name, "splice_site_variant");
assert_eq!(effect.impact, "HIGH");
}
#[test]
fn an_unknown_intronic_offset_is_not_a_splice_site() {
for offset in [
crate::hgvs::parser::position::OFFSET_UNKNOWN_NEGATIVE,
crate::hgvs::parser::position::OFFSET_UNKNOWN_POSITIVE,
] {
let cds_pos = CdsPos {
base: 117,
offset: Some(offset),
utr3: false,
special: None,
};
let effect = predict_cds_effect("deletion", true, false, &cds_pos);
assert_eq!(
effect.name, "intron_variant",
"offset {offset} states no distance, so no splice claim is supported"
);
assert_eq!(effect.impact, "MODIFIER", "offset {offset}");
}
}
#[test]
fn test_predict_splice_region_effect() {
let cds_pos = CdsPos {
base: 117,
offset: Some(-5),
utr3: false,
special: None,
};
let effect = predict_cds_effect("substitution", true, false, &cds_pos);
assert_eq!(effect.name, "splice_region_variant");
assert_eq!(effect.impact, "LOW");
}
#[test]
fn test_predict_intron_effect() {
let cds_pos = CdsPos {
base: 117,
offset: Some(-50),
utr3: false,
special: None,
};
let effect = predict_cds_effect("substitution", true, false, &cds_pos);
assert_eq!(effect.name, "intron_variant");
assert_eq!(effect.impact, "MODIFIER");
}
#[test]
fn test_predict_5_utr_effect() {
let cds_pos = CdsPos {
base: -10,
offset: None,
utr3: false,
special: None,
};
let effect = predict_cds_effect("substitution", false, false, &cds_pos);
assert_eq!(effect.name, "5_prime_UTR_variant");
}
#[test]
fn test_predict_3_utr_effect() {
let cds_pos = CdsPos {
base: 10,
offset: None,
utr3: true,
special: None,
};
let effect = predict_cds_effect("substitution", false, false, &cds_pos);
assert_eq!(effect.name, "3_prime_UTR_variant");
}
#[test]
fn test_predict_frameshift_effect() {
let cds_pos = CdsPos::new(350);
let effect = predict_cds_effect("deletion", false, true, &cds_pos);
assert_eq!(effect.name, "frameshift_variant");
assert_eq!(effect.impact, "HIGH");
}
#[test]
fn span_len_cds_interval_pter_start_returns_none() {
use crate::hgvs::interval::CdsInterval;
let interval = CdsInterval::new(CdsPos::pter(), CdsPos::new(50));
assert_eq!(
span_len_from_cds_interval(&interval),
None,
"pter start is special — span must be None"
);
}
#[test]
fn span_len_cds_interval_qter_end_returns_none() {
use crate::hgvs::interval::CdsInterval;
let interval = CdsInterval::new(CdsPos::new(1), CdsPos::qter());
assert_eq!(
span_len_from_cds_interval(&interval),
None,
"qter end is special — span must be None"
);
}
#[test]
fn span_len_cds_interval_cen_returns_none() {
use crate::hgvs::interval::CdsInterval;
let interval = CdsInterval::new(CdsPos::cen(), CdsPos::new(10));
assert_eq!(
span_len_from_cds_interval(&interval),
None,
"cen start is special — span must be None"
);
}
#[test]
fn span_len_cds_interval_normal_interval_unaffected() {
use crate::hgvs::interval::CdsInterval;
let interval = CdsInterval::new(CdsPos::new(10), CdsPos::new(19));
assert_eq!(
span_len_from_cds_interval(&interval),
Some(10),
"normal CDS interval should still compute span correctly"
);
}
}