use crate::graph::AlignOp;
use crate::{self as poa_consensus, AlignmentMode, ConsensusMode, PoaConfig, PoaError, PoaGraph};
fn b(s: &str) -> Vec<u8> {
s.as_bytes().to_vec()
}
fn s(v: &[u8]) -> String {
String::from_utf8_lossy(v).into_owned()
}
fn consensus(reads: &[Vec<u8>], seed_idx: usize) -> Vec<u8> {
let mut graph = PoaGraph::new(&reads[seed_idx], PoaConfig::default()).unwrap();
for (i, read) in reads.iter().enumerate() {
if i == seed_idx {
continue;
}
graph.add_read(read).unwrap();
}
graph.consensus().unwrap().sequence
}
fn consensus_cfg(reads: &[Vec<u8>], seed_idx: usize, cfg: PoaConfig) -> Vec<u8> {
let mut graph = PoaGraph::new(&reads[seed_idx], cfg).unwrap();
for (i, read) in reads.iter().enumerate() {
if i == seed_idx {
continue;
}
graph.add_read(read).unwrap();
}
graph.consensus().unwrap().sequence
}
#[test]
fn empty_reads() {
let result = PoaGraph::new(&[], PoaConfig::default());
assert!(
matches!(result, Err(PoaError::EmptyInput)),
"expected EmptyInput"
);
}
#[test]
fn below_min_reads() {
let cfg = PoaConfig {
min_reads: 3,
..Default::default()
};
let mut graph = PoaGraph::new(&b("ACGT"), cfg).unwrap();
graph.add_read(&b("ACGT")).unwrap();
let result = graph.consensus();
assert!(
matches!(result, Err(PoaError::InsufficientDepth { got: 2, min: 3 })),
"expected InsufficientDepth, got {:?}",
result
);
}
#[test]
fn seed_out_of_bounds() {
let reads = vec![b("ACGT"), b("ACGT")];
let idx = 5usize;
assert!(idx >= reads.len(), "caller must guard seed_idx before use");
}
#[test]
fn single_read_passthrough() {
let reads = vec![b("CATCATCAT")];
assert_eq!(consensus(&reads, 0), b("CATCATCAT"));
}
#[test]
fn two_identical_reads() {
let reads = vec![b("CATCATCAT"), b("CATCATCAT")];
assert_eq!(consensus(&reads, 0), b("CATCATCAT"));
}
#[test]
fn majority_base_wins() {
let reads = vec![b("CATCATCAT"), b("CATCATCAT"), b("CGTCATCAT")];
assert_eq!(s(&consensus(&reads, 0)), "CATCATCAT");
}
#[test]
fn single_outlier_not_inflated() {
let reads = vec![b("CATCATCAT"), b("CATCATCAT"), b("CATCATCATCAT")];
assert_eq!(consensus(&reads, 0).len(), 9);
}
#[test]
fn length_variation_longer_wins() {
let reads = vec![b("CATCATCATCAT"), b("CATCATCATCAT"), b("CATCATCAT")];
assert_eq!(consensus(&reads, 0).len(), 12);
}
#[test]
fn no_inflation_with_length_noise() {
let reads = vec![
b("CAGCAGCAGCAGCAG"),
b("CAGCAGCAGCAGCAGCAG"),
b("CAGCAGCAGCAGCAG"),
b("CAGCAGCAGCAGCAG"),
];
assert_eq!(consensus(&reads, 0).len(), 15);
}
#[test]
fn no_inflation_phox2b_like() {
let reads = vec![
b("GCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCA"),
b("GCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCA"),
b("GCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCA"),
];
assert_eq!(consensus(&reads, 0).len(), 60);
}
#[test]
fn single_base_reads() {
let reads = vec![b("A"), b("A"), b("A")];
assert_eq!(consensus(&reads, 0), b("A"));
}
#[test]
fn boundary_trim_leading_seed_artifact() {
let reads = vec![
b("XXXCATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result, "CATCATCAT", "got: {}", result);
}
#[test]
fn boundary_trim_trailing_seed_artifact() {
let reads = vec![
b("CATCATCATXXX"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result, "CATCATCAT", "got: {}", result);
}
#[test]
fn diag_sca3_t3_tail_seed_t1() {
let reads = vec![
b("CAGCAGCAGT"),
b("CAGCAGCAGTTT"),
b("CAGCAGCAGTTT"),
b("CAGCAGCAGTTT"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result, "CAGCAGCAGTTT", "got: {}", result);
}
#[test]
fn diag_sca3_t3_tail_seed_t3() {
let reads = vec![
b("CAGCAGCAGTTT"),
b("CAGCAGCAGTTT"),
b("CAGCAGCAGTTT"),
b("CAGCAGCAGT"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result, "CAGCAGCAGTTT", "got: {}", result);
}
#[test]
fn diag_sca31_trailing_interrupt_seed_missing() {
let reads = vec![
b("ATTATTATTATT"),
b("ATTATTATTATTATA"),
b("ATTATTATTATTATA"),
b("ATTATTATTATTATA"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result, "ATTATTATTATTATA", "got: {}", result);
}
#[test]
fn diag_sca3_interrupt_position_single_outlier() {
let maj = b("CAGCAGCAGCAGCAGGTTCAGCAG");
let out = b("CAGCAGCAGCAGCAGCAGGTTCAGCAG");
let reads = vec![maj.clone(), maj.clone(), maj.clone(), out];
let result = s(&consensus(&reads, 0));
assert_eq!(result, "CAGCAGCAGCAGCAGGTTCAGCAG", "got: {}", result);
}
#[test]
fn diag_sca8_minority_trailing_extension_trimmed() {
let base = b("CAGCAGCAGCAGCAG");
let extend = b("CAGCAGCAGCAGCAGGCT");
let reads = vec![
base.clone(),
base.clone(),
base.clone(),
base.clone(),
base.clone(),
base.clone(),
base.clone(),
extend.clone(),
extend.clone(),
extend.clone(),
];
assert_eq!(consensus(&reads, 0).len(), 15);
}
#[test]
fn diag_sca8_minority_trailing_extension_seed_extends() {
let base = b("CAGCAGCAGCAGCAG");
let extend = b("CAGCAGCAGCAGCAGGCT");
let reads = vec![
extend.clone(),
extend.clone(),
extend.clone(),
base.clone(),
base.clone(),
base.clone(),
base.clone(),
base.clone(),
base.clone(),
base.clone(),
];
assert_eq!(consensus(&reads, 0).len(), 15);
}
#[test]
fn diag_sca31_trailing_interrupt_before_flank() {
let flank = b("GCGCGCGC");
let mut seed_read = b("ATTATTATTATT");
seed_read.extend_from_slice(&flank);
let mut maj_read = b("ATTATTATTATTATA");
maj_read.extend_from_slice(&flank);
let reads = vec![
seed_read,
maj_read.clone(),
maj_read.clone(),
maj_read.clone(),
];
let result = s(&consensus(&reads, 0));
let expected: String = "ATTATTATTATTATA"
.chars()
.chain("GCGCGCGC".chars())
.collect();
assert_eq!(result, expected, "got: {}", result);
}
#[test]
fn diag_sca3_interrupt_position_long_repeat_with_flank() {
let flank = b("CTGCTGCTG");
let make = |repeat_pre: &str, interrupt: &str, repeat_post: &str| -> Vec<u8> {
let mut v = repeat_pre.as_bytes().to_vec();
v.extend_from_slice(interrupt.as_bytes());
v.extend_from_slice(repeat_post.as_bytes());
v.extend_from_slice(&flank);
v
};
let maj = make("CAGCAGCAGCAGCAGCAGCAGCAG", "GTT", "CAGCAGCAG");
let out = make("CAGCAGCAGCAGCAGCAGCAGCAGCAG", "GTT", "CAGCAG");
let reads = vec![maj.clone(), maj.clone(), maj.clone(), out];
let result = s(&consensus(&reads, 0));
let expected = s(&make("CAGCAGCAGCAGCAGCAGCAGCAG", "GTT", "CAGCAGCAG"));
assert_eq!(result, expected, "got: {}", result);
}
#[test]
#[ignore]
fn diag_frda_gaa_rotation_phase() {
let gaa_phase = b("GAAGAAGAAGAA");
let aag_phase = b("AAGAAGAAGAAG");
let aga_phase = b("AGAAGAAGAAGA");
let reads = vec![gaa_phase.clone(), gaa_phase.clone(), aag_phase, aga_phase];
let result = s(&consensus(&reads, 0));
assert_eq!(result.len(), 12, "got: '{}'", result);
}
#[test]
fn diag_frda_gaa_rotation_with_flanking() {
let make = |repeat: &str| -> Vec<u8> {
let mut v = b("TTTCCC");
v.extend_from_slice(repeat.as_bytes());
v.extend_from_slice(b("GGGAAA").as_slice());
v
};
let reads = vec![
make("GAAGAAGAAGAA"),
make("GAAGAAGAAGAA"),
make("AAGAAGAAGAAG"),
make("AGAAGAAGAAGA"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result.len(), 24, "got: '{}'", result);
}
#[test]
fn diag_phase_shift_first_node_coverage() {
let reads = vec![
b("GAAGAA"),
b("GAAGAA"),
b("GAAGAA"),
b("GAAGAA"),
b("AAGAAG"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result, "GAAGAA", "got: '{}'", result);
}
#[test]
fn diag_phase_shift_majority_trims_first_base() {
let reads = vec![
b("GAAGAA"),
b("GAAGAA"),
b("AAGAAG"),
b("AAGAAG"),
b("AAGAAG"),
];
let result = s(&consensus(&reads, 0));
assert_eq!(result.len(), 6, "got: '{}'", result);
}
#[test]
fn diag_sca3_t3_tail_with_flank() {
let flank = b("CCTCCTCCT");
let make = |tail: &str| -> Vec<u8> {
let mut v = b("CAGCAGCAG");
v.extend_from_slice(tail.as_bytes());
v.extend_from_slice(&flank);
v
};
let reads = vec![make("T"), make("TTT"), make("TTT"), make("TTT")];
let result = s(&consensus(&reads, 0));
let expected = s(&make("TTT"));
assert_eq!(result, expected, "got: {}", result);
}
#[test]
fn diag_sca8_real_sequences_no_flank() {
let maj57 = b("TACTACTACTACTACTACTACTACTACTACTACTGCTGCTGCTGCTGCTGCTGCTGCT");
let min75 = b("TACTACTACTACTACTACTACTACTACTACTACTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCT");
let min81 =
b("TACTACTACTACTACTACTACTACTACTACTACTACTACTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCT");
let mut reads: Vec<Vec<u8>> = std::iter::repeat(maj57.clone()).take(32).collect();
reads.push(b(
"TACTACTACTACTACTACTACTACTACTACTACTACTACTACTACTACTACTACTAC",
));
reads.push(b(
"TACTACTACTACTACTACTACTACTACTACTACTACTACTACTACTGCTGCTGCTGCT",
));
reads.push(b("TACTACTACTACTACTACTACTACTGCTGCTGCTGCTGCTGCTGCTGCT"));
reads.push(min75.clone());
reads.push(min81.clone());
reads.push(min81.clone());
let result = consensus(&reads, 0);
assert_eq!(
result.len(),
maj57.len(),
"SCA8 consensus must match majority length {}, got len {}: '{}'",
maj57.len(),
result.len(),
s(&result)
);
}
#[test]
fn diag_sca8_real_sequences_with_flank() {
let flank_l = b("GCTTCGAAGTC");
let flank_r = b("AAACGGTTCCA");
let make = |repeat: &[u8]| -> Vec<u8> {
let mut v = flank_l.clone();
v.extend_from_slice(repeat);
v.extend_from_slice(&flank_r);
v
};
let maj = make(&b(
"TACTACTACTACTACTACTACTACTACTACTACTGCTGCTGCTGCTGCTGCTGCTGCT",
));
let min75 = make(&b(
"TACTACTACTACTACTACTACTACTACTACTACTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCT",
));
let min81 = make(&b(
"TACTACTACTACTACTACTACTACTACTACTACTACTACTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCTGCT",
));
let mut reads: Vec<Vec<u8>> = std::iter::repeat(maj.clone()).take(32).collect();
reads.push(min75);
reads.push(min81.clone());
reads.push(min81);
let result_len = consensus(&reads, 0).len();
assert_eq!(
result_len,
maj.len(),
"SCA8 flanked: got {}, expected {}",
result_len,
maj.len()
);
}
#[test]
fn banded_matches_unbanded_small() {
let reads = vec![
b("CAGCAGCAGCAGCAG"),
b("CAGCAGCAGCAGCAG"),
b("CAGCAGCAGCAGCAGCAG"),
b("CAGCAGCAGCAGCAG"),
];
let unbanded = consensus(&reads, 0);
let cfg_banded = PoaConfig {
band_width: 50,
..Default::default()
};
let banded = consensus_cfg(&reads, 0, cfg_banded);
assert_eq!(unbanded, banded, "banded vs unbanded mismatch");
}
#[test]
fn adaptive_band_matches_unbanded() {
let reads = vec![
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCATCAT"),
b("CATCATCAT"),
];
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
adaptive_band: true,
adaptive_band_b: 5,
adaptive_band_f: 0.1,
..Default::default()
};
let adaptive = consensus_cfg(&reads, 0, cfg);
assert_eq!(unbanded, adaptive, "adaptive band vs unbanded mismatch");
}
#[test]
fn band_too_narrow_fallback_to_unbanded() {
let seed = b("A");
let read = b("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); let cfg = PoaConfig {
band_width: 2,
..Default::default()
};
let mut graph = PoaGraph::new(&seed, cfg).unwrap();
let result = graph.add_read(&read);
assert!(
result.is_ok(),
"3-pass retry must recover via unbanded fallback, got {:?}",
result.map(|_| ())
);
}
#[test]
fn large_length_variance_banded() {
let maj = b("CAGCAGCAGCAGCAG");
let outlier = b("CAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAGCAG");
let reads = vec![maj.clone(), maj.clone(), maj.clone(), outlier];
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
band_width: 50,
..Default::default()
};
let banded = consensus_cfg(&reads, 0, cfg);
assert_eq!(
banded.len(),
unbanded.len(),
"banded length mismatch with large variance"
);
assert_eq!(
banded, unbanded,
"banded result mismatch with large variance"
);
}
#[test]
fn partial_reads_semi_global() {
let full = b("ACGTACGTACGT");
let partial = b("ACGTACGT");
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
..Default::default()
};
let reads = vec![full.clone(), full.clone(), full.clone(), partial];
let result = consensus_cfg(&reads, 0, cfg);
assert_eq!(
result.len(),
12,
"semi-global: got len {}, seq: '{}'",
result.len(),
s(&result)
);
}
#[test]
fn one_spanning_many_partial_default_min_cov_truncates() {
let spanning = b("ACGTACGTACGT"); let partial = b("ACGTACGT"); let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
..Default::default()
};
let reads = vec![
spanning.clone(),
partial.clone(),
partial.clone(),
partial.clone(),
partial.clone(),
];
let result = consensus_cfg(&reads, 0, cfg);
assert!(
result.len() < 12,
"expected boundary trim with default min_cov, got len {} seq '{}'",
result.len(),
s(&result)
);
}
#[test]
fn one_spanning_many_partial_low_min_cov_reaches_partial_end() {
let spanning = b("ACGTACGTACGT");
let partial = b("ACGTACGT");
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let reads = vec![
spanning.clone(),
partial.clone(),
partial.clone(),
partial.clone(),
partial.clone(),
];
let result = consensus_cfg(&reads, 0, cfg);
assert!(
result.len() >= 8,
"consensus should reach at least the partial read end, got len {} seq '{}'",
result.len(),
s(&result)
);
}
#[test]
fn two_spanning_many_partial_full_length() {
let spanning = b("ACGTACGTACGT");
let partial = b("ACGTACGT");
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let reads = vec![
spanning.clone(),
spanning.clone(), partial.clone(),
partial.clone(),
partial.clone(),
partial.clone(),
];
let result = consensus_cfg(&reads, 0, cfg);
assert_eq!(
result.len(),
12,
"two spanning reads should yield full-length consensus, got len {} seq '{}'",
result.len(),
s(&result)
);
}
#[test]
fn overlapping_partial_reads_assemble_beyond_seed_length() {
let left = b("ACGTACGTACGT"); let right = b("ACGTACGTACGT"); let seed = b("ACGTACGT"); let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let reads = vec![
seed.clone(),
left.clone(),
left.clone(),
left.clone(),
right.clone(),
right.clone(),
right.clone(),
];
let result = consensus_cfg(&reads, 0, cfg);
assert!(
result.len() >= seed.len(),
"assembled consensus should be at least as long as the seed, got len {} seq '{}'",
result.len(),
s(&result)
);
}
#[test]
fn coverage_vec_reflects_partial_read_depth() {
let spanning = b("ACGTACGTACGT"); let partial = b("ACGTACGT"); let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let mut graph = PoaGraph::new(&spanning, cfg).unwrap();
for _ in 0..4 {
graph.add_read(&partial).unwrap();
}
let cons = graph.consensus().unwrap();
let prefix_min = cons.coverage[..8].iter().copied().min().unwrap_or(0);
let suffix_max = cons.coverage[8..].iter().copied().max().unwrap_or(0);
assert!(
prefix_min > suffix_max,
"prefix coverage ({}) should exceed suffix coverage ({})",
prefix_min,
suffix_max
);
}
#[test]
fn no_gap_when_reads_overlap() {
let seed = b("ACGTTGCAATGC"); let left = b("ACGTTGCA"); let right = b("GCAATGC"); let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let mut graph = PoaGraph::new(&seed, cfg).unwrap();
for _ in 0..3 {
graph.add_read(&left).unwrap();
}
for _ in 0..3 {
graph.add_read(&right).unwrap();
}
let cons = graph.consensus().unwrap();
assert!(
cons.gaps.is_empty(),
"overlapping partials should produce no coverage gap; got {:?}",
cons.gaps
);
}
#[test]
fn gap_detected_when_partials_dont_overlap() {
let seed = b("ACGTTGCAATGCCCGG"); let left = b("ACGTT"); let right = b("CCCGG"); let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let mut graph = PoaGraph::new(&seed, cfg).unwrap();
for _ in 0..3 {
graph.add_read(&left).unwrap();
}
for _ in 0..3 {
graph.add_read(&right).unwrap();
}
let cons = graph.consensus().unwrap();
assert!(
!cons.gaps.is_empty(),
"non-overlapping partials should produce a coverage gap; coverage={:?}",
cons.coverage
);
let gap = &cons.gaps[0];
assert!(
gap.size() >= 6,
"gap should span the 6 seed-only positions: {:?}",
gap
);
assert_eq!(gap.start + gap.size(), gap.end);
}
#[test]
fn gap_size_is_minimum_size_estimate() {
let seed = b("ACGTTGCAATGCCCGGTTAA"); let left = b("ACGTT"); let right = b("GTTAA"); let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let mut graph = PoaGraph::new(&seed, cfg).unwrap();
for _ in 0..4 {
graph.add_read(&left).unwrap();
}
for _ in 0..4 {
graph.add_read(&right).unwrap();
}
let cons = graph.consensus().unwrap();
assert!(
!cons.gaps.is_empty(),
"expected a gap; coverage: {:?}",
cons.coverage
);
let total_gap: usize = cons.gaps.iter().map(|g| g.size()).sum();
assert!(
total_gap >= 10,
"expected gap ≥ 10 bp, got {total_gap}; gaps: {:?}",
cons.gaps
);
}
#[test]
fn single_read_has_no_gap() {
let cfg = PoaConfig {
min_reads: 1,
..Default::default()
};
let graph = PoaGraph::new(b"ACGTTGCAATGC", cfg).unwrap();
let cons = graph.consensus().unwrap();
assert!(
cons.gaps.is_empty(),
"single-read consensus should have no gaps"
);
}
#[test]
fn gap_kind_spanning_for_seed_based_gap() {
let seed = b("ACGTTGCAATGCCCGG");
let left = b("ACGTT");
let right = b("CCCGG");
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let mut graph = PoaGraph::new(&seed, cfg).unwrap();
for _ in 0..3 {
graph.add_read(&left).unwrap();
}
for _ in 0..3 {
graph.add_read(&right).unwrap();
}
let cons = graph.consensus().unwrap();
assert!(!cons.gaps.is_empty());
assert_eq!(
cons.gaps[0].kind,
poa_consensus::GapKind::Spanning,
"seed-based gaps must be Spanning"
);
assert_eq!(cons.gaps[0].min_size(), Some(cons.gaps[0].size()));
}
#[test]
fn bridged_consensus_unknown_gap() {
let left_reads: Vec<Vec<u8>> = (0..4).map(|_| b("ACGTTGCA")).collect();
let right_reads: Vec<Vec<u8>> = (0..4).map(|_| b("ATGCCCGG")).collect();
let left_refs: Vec<&[u8]> = left_reads.iter().map(|r| r.as_slice()).collect();
let right_refs: Vec<&[u8]> = right_reads.iter().map(|r| r.as_slice()).collect();
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
..Default::default()
};
let cons = poa_consensus::bridged_consensus(&left_refs, 0, &right_refs, 0, &cfg).unwrap();
assert!(!cons.sequence.is_empty());
let unknown: Vec<_> = cons
.gaps
.iter()
.filter(|g| g.kind == poa_consensus::GapKind::Unknown)
.collect();
assert_eq!(unknown.len(), 1, "expected exactly one Unknown gap");
let gap = unknown[0];
assert_eq!(
gap.start, gap.end,
"Unknown gap should be an insertion point (start==end)"
);
assert_eq!(gap.min_size(), None, "Unknown gap has no minimum size");
assert!(cons.sequence.len() > 0);
assert_eq!(cons.n_reads, 8);
}
#[test]
fn path_weights_reflect_edge_support() {
let spanning = b("ACGTACGTACGT");
let partial = b("ACGTACGT");
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let mut graph = PoaGraph::new(&spanning, cfg).unwrap();
for _ in 0..4 {
graph.add_read(&partial).unwrap();
}
let cons = graph.consensus().unwrap();
assert_eq!(cons.n_reads, 5);
assert_eq!(cons.path_weights.len(), cons.sequence.len());
for (i, &w) in cons.path_weights.iter().enumerate() {
assert!(
w >= 2,
"position {i}: weight {w} should be ≥ 2 (shared by seed + partial)"
);
}
}
#[test]
fn weight_fraction_in_unit_interval() {
let reads = vec![b("ACGTACGT"); 5];
let cons = consensus_cfg(&reads, 0, PoaConfig::default());
let mut graph = PoaGraph::new(&reads[0], PoaConfig::default()).unwrap();
for r in &reads[1..] {
graph.add_read(r).unwrap();
}
let c = graph.consensus().unwrap();
let fracs = c.weight_fraction();
assert_eq!(fracs.len(), cons.len());
for (i, &f) in fracs.iter().enumerate() {
assert!(
(0.0..=1.0).contains(&f),
"position {i}: fraction {f} out of [0,1]"
);
}
for (i, &f) in fracs.iter().enumerate() {
assert!(
(f - 1.0).abs() < 1e-6,
"position {i}: expected fraction 1.0, got {f}"
);
}
}
#[test]
fn weight_fraction_drops_at_single_read_positions() {
let spanning = b("ACGTTGCAATGC"); let partial = b("ACGTTGCA"); let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
min_coverage_fraction: 0.1,
..Default::default()
};
let mut graph = PoaGraph::new(&spanning, cfg.clone()).unwrap();
graph.add_read(&spanning).unwrap();
for _ in 0..4 {
graph.add_read(&partial).unwrap();
}
let cons = graph.consensus().unwrap();
let fracs = cons.weight_fraction();
assert_eq!(cons.sequence.len(), 12, "expected full-length consensus");
let prefix_frac: f32 = fracs[..8].iter().copied().sum::<f32>() / 8.0;
let suffix_frac: f32 = fracs[8..].iter().copied().sum::<f32>() / 4.0;
assert!(
prefix_frac > suffix_frac,
"prefix avg fraction ({prefix_frac:.2}) should exceed suffix ({suffix_frac:.2})"
);
}
#[test]
fn semi_global_no_prefix_deletes_for_mid_start_read() {
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
band_width: 0,
..Default::default()
};
let graph = PoaGraph::new(b"GGACGT", cfg).unwrap();
let (ops, _) = graph.align_read_ops_unbanded(b"ACGT").unwrap();
let n_del = ops
.iter()
.filter(|op| matches!(op, AlignOp::Delete(_)))
.count();
let n_mat = ops
.iter()
.filter(|op| matches!(op, AlignOp::Match(_)))
.count();
assert_eq!(
n_del, 0,
"semi-global: expected no prefix Deletes, got {:?}",
ops
);
assert_eq!(n_mat, 4, "semi-global: expected 4 Matches, got {:?}", ops);
}
#[test]
fn global_produces_prefix_deletes_for_mid_start_read() {
let cfg = PoaConfig {
band_width: 0,
..Default::default()
};
let graph = PoaGraph::new(b"GGACGT", cfg).unwrap();
let (ops, _) = graph.align_read_ops_unbanded(b"ACGT").unwrap();
let n_del = ops
.iter()
.filter(|op| matches!(op, AlignOp::Delete(_)))
.count();
assert!(
n_del > 0,
"global: expected prefix Delete ops for mid-start read"
);
}
#[test]
fn semi_global_spanning_read_matches_global() {
let reads = vec![b("ACGTACGT"); 4];
let global = consensus(&reads, 0);
let cfg = PoaConfig {
alignment_mode: AlignmentMode::SemiGlobal,
..Default::default()
};
let semi = consensus_cfg(&reads, 0, cfg);
assert_eq!(
global, semi,
"spanning reads: semi-global must equal global"
);
}
#[test]
fn reverse_complement_basic() {
use crate::reverse_complement;
assert_eq!(reverse_complement(b"ACGT"), b"ACGT");
assert_eq!(reverse_complement(b"AAAA"), b"TTTT");
assert_eq!(reverse_complement(b"GCTA"), b"TAGC");
}
#[test]
fn orient_to_seed_forward() {
use crate::Strand;
use crate::orient_to_seed;
let seed = b("ACGTACGTACGT");
let read = b("ACGTACGT");
assert_eq!(orient_to_seed(&read, &seed, 4), Strand::Forward);
}
#[test]
fn orient_to_seed_reverse() {
use crate::Strand;
use crate::orient_to_seed;
let seed = b("AAAACCCCGGGG");
let rc = crate::reverse_complement(&seed);
assert_eq!(orient_to_seed(&rc, &seed, 4), Strand::Reverse);
}
#[test]
fn mixed_strand_input() {
use crate::auto_orient;
let seed = b("CATCATCAT");
let rc = crate::reverse_complement(&seed);
let reads = vec![seed.clone(), seed.clone(), rc.clone(), rc.clone()];
let oriented: Vec<Vec<u8>> = auto_orient(&reads, 0)
.into_iter()
.map(|c| c.into_owned())
.collect();
let mut graph = PoaGraph::new(&oriented[0], PoaConfig::default()).unwrap();
for r in &oriented[1..] {
graph.add_read(r).unwrap();
}
let result = graph.consensus().unwrap().sequence;
assert_eq!(result.len(), 9, "mixed strand: got len {}", result.len());
}
fn mf_cfg() -> PoaConfig {
PoaConfig {
consensus_mode: ConsensusMode::MajorityFrequency,
..Default::default()
}
}
fn mf_consensus(reads: &[Vec<u8>], seed_idx: usize) -> Vec<u8> {
consensus_cfg(reads, seed_idx, mf_cfg())
}
#[test]
fn mf_identical_reads() {
let reads = vec![b("CATCATCAT"), b("CATCATCAT"), b("CATCATCAT")];
assert_eq!(mf_consensus(&reads, 0), b("CATCATCAT"));
}
#[test]
fn mf_matches_hb_on_clean_input() {
let reads = vec![
b("CAGCAGCAG"),
b("CAGCAGCAG"),
b("CAGCAGCAGCAG"),
b("CAGCAGCAG"),
];
let hb = consensus(&reads, 0);
let mf = mf_consensus(&reads, 0);
assert_eq!(hb, mf, "HB and MF disagree on clean input");
}
#[test]
fn mf_boundary_trim_leading() {
let reads = vec![
b("XXXCATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
];
let result = s(&mf_consensus(&reads, 0));
assert_eq!(result, "CATCATCAT", "got: {}", result);
}
#[test]
fn mf_boundary_trim_trailing() {
let reads = vec![
b("CATCATCATXXX"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
];
let result = s(&mf_consensus(&reads, 0));
assert_eq!(result, "CATCATCAT", "got: {}", result);
}
#[test]
fn mf_majority_base_wins() {
let reads = vec![
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CGTCATCAT"),
];
assert_eq!(s(&mf_consensus(&reads, 0)), "CATCATCAT");
}
#[test]
fn mf_single_outlier_not_inflated() {
let reads = vec![b("CATCATCAT"), b("CATCATCAT"), b("CATCATCATCAT")];
assert_eq!(mf_consensus(&reads, 0).len(), 9);
}
fn build_graph(reads: &[Vec<u8>], seed_idx: usize) -> PoaGraph {
let mut graph = PoaGraph::new(&reads[seed_idx], PoaConfig::default()).unwrap();
for (i, read) in reads.iter().enumerate() {
if i != seed_idx {
graph.add_read(read).unwrap();
}
}
graph
}
#[test]
fn stats_clean_linear_no_bubbles() {
let reads = vec![
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
];
let st = build_graph(&reads, 0).stats();
assert_eq!(st.bubble_count, 0);
assert_eq!(st.max_bubble_depth, 0);
assert_eq!(st.node_count, 9);
assert_eq!(st.mean_column_entropy, 0.0);
}
#[test]
fn stats_bubble_detected() {
let reads = vec![
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CGTCATCAT"),
];
let st = build_graph(&reads, 0).stats();
assert_eq!(st.bubble_count, 1, "expected 1 bubble");
assert_eq!(st.max_bubble_depth, 1, "minority arm weight should be 1");
}
#[test]
fn stats_entropy_nonzero_on_length_variation() {
let reads = vec![
b("XXXCATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
];
let st = build_graph(&reads, 0).stats();
assert!(
st.mean_column_entropy > 0.0,
"expected nonzero entropy, got {}",
st.mean_column_entropy
);
}
#[test]
fn stats_node_edge_counts() {
let reads = vec![b("CATCATCAT"), b("CATCATCATCAT")];
let st = build_graph(&reads, 0).stats();
assert_eq!(st.node_count, 12, "9 + 3 extra nodes");
assert_eq!(st.edge_count, 11);
}
#[test]
fn stats_coverage_mean_uniform() {
let reads = vec![
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
b("CATCATCAT"),
];
let st = build_graph(&reads, 0).stats();
assert!((st.coverage_mean - 4.0).abs() < 1e-10);
assert!(st.coverage_variance < 1e-10);
}
fn multi_graph(reads: &[Vec<u8>], seed_idx: usize) -> PoaGraph {
let mut graph = PoaGraph::new(&reads[seed_idx], PoaConfig::default()).unwrap();
for (i, read) in reads.iter().enumerate() {
if i != seed_idx {
graph.add_read(read).unwrap();
}
}
graph
}
#[test]
fn consensus_multi_single_allele() {
let reads = vec![b("CATCATCAT"); 4];
let g = multi_graph(&reads, 0);
let results = g.consensus_multi().unwrap();
assert_eq!(results.len(), 1, "expected 1 allele for homozygous input");
assert_eq!(results[0].sequence, b("CATCATCAT"));
}
#[test]
fn consensus_multi_snv_bubble() {
let allele_a = b("CATCATCAT");
let allele_b = b("CATCGTCAT");
let reads: Vec<Vec<u8>> = (0..4)
.map(|_| allele_a.clone())
.chain((0..4).map(|_| allele_b.clone()))
.collect();
let g = multi_graph(&reads, 0);
let results = g.consensus_multi().unwrap();
assert_eq!(results.len(), 2, "expected 2 alleles for SNV input");
let seqs: Vec<String> = results.iter().map(|c| s(&c.sequence)).collect();
assert!(
seqs.iter().any(|seq| seq == "CATCATCAT"),
"missing CATCATCAT allele: {:?}",
seqs
);
assert!(
seqs.iter().any(|seq| seq == "CATCGTCAT"),
"missing CATCGTCAT allele: {:?}",
seqs
);
}
#[test]
fn consensus_multi_length_variation() {
let short = b("AAACATCATTTTTT");
let long_ = b("AAACATCATCATTTTTT");
let reads: Vec<Vec<u8>> = (0..4)
.map(|_| short.clone())
.chain((0..4).map(|_| long_.clone()))
.collect();
let g = multi_graph(&reads, 0);
let results = g.consensus_multi().unwrap();
assert_eq!(
results.len(),
2,
"expected 2 alleles for length-variation input"
);
let lens: Vec<usize> = results.iter().map(|c| c.sequence.len()).collect();
assert!(lens.contains(&14), "expected 14-bp allele; got {:?}", lens);
assert!(lens.contains(&17), "expected 17-bp allele; got {:?}", lens);
}
#[test]
fn consensus_multi_insufficient_depth_per_allele() {
let allele_a = b("CATCATCAT");
let allele_b = b("CATCGTCAT");
let reads = vec![allele_a.clone(), allele_a, allele_b.clone(), allele_b];
let cfg = PoaConfig {
min_reads: 3,
..Default::default()
};
let mut g = PoaGraph::new(&reads[0], cfg).unwrap();
for r in &reads[1..] {
g.add_read(r).unwrap();
}
let result = g.consensus_multi();
assert!(
matches!(result, Err(PoaError::InsufficientDepth { .. })),
"expected InsufficientDepth, got {:?}",
result.map(|v| v.len())
);
}
#[test]
fn long_repeat_consensus_correctness() {
let seq: Vec<u8> = "CAT".repeat(30).into_bytes(); let reads = vec![seq.clone(); 6];
assert_eq!(consensus(&reads, 0), seq, "30×CAT consensus mismatch");
}
#[test]
fn long_repeat_length_majority_wins() {
let maj: Vec<u8> = "CAT".repeat(20).into_bytes();
let out: Vec<u8> = "CAT".repeat(21).into_bytes();
let mut reads: Vec<Vec<u8>> = vec![maj.clone(); 8];
reads.extend(vec![out; 2]);
let result = consensus(&reads, 0);
assert_eq!(
result.len(),
60,
"expected 60-bp majority, got {} bp",
result.len()
);
}
#[test]
fn long_repeat_snv_correction() {
let correct: Vec<u8> = "CAT".repeat(20).into_bytes(); let mut noisy = correct.clone();
noisy[30] = b'G';
let mut reads: Vec<Vec<u8>> = vec![correct.clone(); 9];
reads.push(noisy);
let result = consensus(&reads, 0);
assert_eq!(
result, correct,
"SNV from single noisy read should not affect consensus"
);
}
#[test]
fn long_banded_matches_unbanded() {
let base: Vec<u8> = "CAT".repeat(24).into_bytes(); let long: Vec<u8> = "CAT".repeat(26).into_bytes(); let mut reads: Vec<Vec<u8>> = vec![base.clone(); 4];
reads.push(long);
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
band_width: 30,
..Default::default()
};
let banded = consensus_cfg(&reads, 0, cfg);
assert_eq!(
banded, unbanded,
"banded(30) should match unbanded for small divergence"
);
}
#[test]
fn long_adaptive_band_matches_unbanded() {
let base: Vec<u8> = "CAT".repeat(24).into_bytes(); let long: Vec<u8> = "CAT".repeat(27).into_bytes(); let mut reads: Vec<Vec<u8>> = vec![base.clone(); 4];
reads.push(long);
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
adaptive_band: true,
adaptive_band_b: 10,
adaptive_band_f: 0.05,
..Default::default()
};
let adaptive = consensus_cfg(&reads, 0, cfg);
assert_eq!(
adaptive, unbanded,
"adaptive band should match unbanded for small divergence"
);
}
#[test]
fn skip_fires_on_clean_reads() {
let read: Vec<u8> = "CAT".repeat(10).into_bytes(); let reads: Vec<Vec<u8>> = vec![read.clone(); 10];
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
band_width: 5,
..Default::default()
};
let banded = consensus_cfg(&reads, 0, cfg);
assert_eq!(
banded, unbanded,
"diagonal skip: banded must match unbanded on identical reads"
);
assert_eq!(
banded, read,
"diagonal skip: consensus of identical reads must equal the read"
);
}
#[test]
fn tracking_band_survives_phase_shift() {
let base: Vec<u8> = "ACGT".repeat(15).into_bytes(); let shifted: Vec<u8> = {
let mut s = b"AAAAAAAAAA".to_vec(); s.extend_from_slice(&base);
s
}; let mut reads: Vec<Vec<u8>> = vec![base.clone(); 4];
reads.push(shifted);
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
band_width: 5,
..Default::default()
};
let banded = consensus_cfg(&reads, 0, cfg);
assert_eq!(
banded, unbanded,
"tracking band: must match unbanded on reads with a large phase shift"
);
}
#[test]
fn sv_retry_correct() {
let short: Vec<u8> = "CAT".repeat(3).into_bytes(); let expanded: Vec<u8> = "CAT".repeat(8).into_bytes(); let mut reads: Vec<Vec<u8>> = vec![short.clone(); 5];
reads.push(expanded);
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
band_width: 3,
..Default::default()
};
let banded = consensus_cfg(&reads, 0, cfg);
assert_eq!(
banded, unbanded,
"sv_retry: smart retry must produce correct consensus when SV read forces band widening"
);
}
#[test]
fn consensus_multi_long_flanked_str() {
let flank_l = b("GGGGG");
let flank_r = b("AAAAA");
let inner_a: Vec<u8> = "CAT".repeat(8).into_bytes();
let inner_b: Vec<u8> = "CAT".repeat(11).into_bytes();
let allele_a: Vec<u8> = [flank_l.as_slice(), inner_a.as_slice(), flank_r.as_slice()].concat();
let allele_b: Vec<u8> = [flank_l.as_slice(), inner_b.as_slice(), flank_r.as_slice()].concat();
let mut reads: Vec<Vec<u8>> = vec![allele_a.clone(); 5];
reads.extend(vec![allele_b.clone(); 5]);
let mut g = PoaGraph::new(&reads[0], PoaConfig::default()).unwrap();
for r in &reads[1..] {
g.add_read(r).unwrap();
}
let results = g.consensus_multi().unwrap();
let lens: Vec<usize> = results.iter().map(|c| c.sequence.len()).collect();
assert_eq!(results.len(), 2, "expected 2 alleles; got {:?}", lens);
assert!(lens.contains(&34), "expected 34-bp allele; got {:?}", lens);
assert!(lens.contains(&43), "expected 43-bp allele; got {:?}", lens);
}
#[test]
fn consensus_multi_snv_in_long_context() {
let a: Vec<u8> = {
let mut v = vec![b'T'; 41];
v[10] = b'A';
v
};
let bv: Vec<u8> = vec![b'T'; 41];
let mut reads: Vec<Vec<u8>> = vec![a.clone(); 5];
reads.extend(vec![bv.clone(); 5]);
let mut g = PoaGraph::new(&reads[0], PoaConfig::default()).unwrap();
for r in &reads[1..] {
g.add_read(r).unwrap();
}
let results = g.consensus_multi().unwrap();
assert_eq!(
results.len(),
2,
"expected 2 alleles for SNV; got {}",
results.len()
);
let seqs: Vec<Vec<u8>> = results.into_iter().map(|c| c.sequence).collect();
assert!(
seqs.iter().any(|s| s == &a),
"allele_a (A at pos 10) not found in results"
);
assert!(
seqs.iter().any(|s| s == &bv),
"allele_b (all-T) not found in results"
);
}
#[test]
fn consensus_multi_skewed_allele_ratio() {
let flank_l = b("TTTT");
let flank_r = b("CCCC");
let inner_a: Vec<u8> = "CAT".repeat(6).into_bytes();
let inner_b: Vec<u8> = "CAT".repeat(10).into_bytes();
let allele_a: Vec<u8> = [flank_l.as_slice(), inner_a.as_slice(), flank_r.as_slice()].concat();
let allele_b: Vec<u8> = [flank_l.as_slice(), inner_b.as_slice(), flank_r.as_slice()].concat();
let mut reads: Vec<Vec<u8>> = vec![allele_a.clone(); 7];
reads.extend(vec![allele_b.clone(); 3]);
let mut g = PoaGraph::new(&reads[0], PoaConfig::default()).unwrap();
for r in &reads[1..] {
g.add_read(r).unwrap();
}
let results = g.consensus_multi().unwrap();
let lens: Vec<usize> = results.iter().map(|c| c.sequence.len()).collect();
assert_eq!(
results.len(),
2,
"expected 2 alleles at 7:3; got {:?}",
lens
);
assert!(
lens.contains(&26),
"expected 26-bp majority allele; got {:?}",
lens
);
assert!(
lens.contains(&38),
"expected 38-bp minor allele; got {:?}",
lens
);
}
#[test]
fn long_reads_noise_and_banding() {
let correct: Vec<u8> = "CAT".repeat(21).into_bytes(); let mut err1 = correct.clone();
err1[20] = b'G';
let mut err2 = correct.clone();
err2[45] = b'T';
let mut reads: Vec<Vec<u8>> = vec![correct.clone(); 6];
reads.extend(vec![err1; 2]);
reads.extend(vec![err2; 2]);
let cfg = PoaConfig {
band_width: 40,
..Default::default()
};
let result = consensus_cfg(&reads, 0, cfg);
assert_eq!(
result, correct,
"banded consensus should correct isolated noise in 63-bp reads"
);
}
const BASE_60: &[u8] = b"ATCGATCGTTACGATCGTAGCTAGTCATGCTAATCGTAGCGATCGTAACGATCGATCGTA";
#[test]
fn long_nonrepeat_consensus_correctness() {
let reads = vec![BASE_60.to_vec(); 6];
assert_eq!(
consensus(&reads, 0),
BASE_60,
"6 identical non-repeat reads"
);
}
#[test]
fn long_nonrepeat_snv_correction() {
let mut noisy = BASE_60.to_vec();
noisy[30] = b'G';
let mut reads: Vec<Vec<u8>> = vec![BASE_60.to_vec(); 9];
reads.push(noisy);
assert_eq!(
consensus(&reads, 0),
BASE_60,
"single noisy read must not flip consensus base"
);
}
#[test]
fn long_nonrepeat_banded_matches_unbanded() {
let mut long = BASE_60.to_vec();
long.splice(30..30, *b"GCTAGC");
assert_eq!(long.len(), 66);
let mut reads: Vec<Vec<u8>> = vec![BASE_60.to_vec(); 4];
reads.push(long);
let unbanded = consensus(&reads, 0);
let cfg = PoaConfig {
band_width: 30,
..Default::default()
};
let banded = consensus_cfg(&reads, 0, cfg);
assert_eq!(
banded, unbanded,
"banded(30) should match unbanded on non-repeat sequence"
);
}
#[test]
fn consensus_multi_nonrepeat_snv() {
let mut allele_b = BASE_60.to_vec();
allele_b[30] = b'G';
let mut reads: Vec<Vec<u8>> = vec![BASE_60.to_vec(); 5];
reads.extend(vec![allele_b.clone(); 5]);
let mut g = PoaGraph::new(&reads[0], PoaConfig::default()).unwrap();
for r in &reads[1..] {
g.add_read(r).unwrap();
}
let results = g.consensus_multi().unwrap();
assert_eq!(results.len(), 2, "expected 2 alleles for non-repeat SNV");
let seqs: Vec<Vec<u8>> = results.into_iter().map(|c| c.sequence).collect();
assert!(
seqs.iter().any(|s| s.as_slice() == BASE_60),
"allele_a not recovered"
);
assert!(
seqs.iter().any(|s| s == &allele_b),
"allele_b not recovered"
);
}
#[test]
fn fn_consensus_basic() {
let reads: Vec<&[u8]> = vec![b"CATCATCAT", b"CATCATCAT", b"CATCATCAT"];
let result = poa_consensus::consensus(&reads, 0, &PoaConfig::default()).unwrap();
assert_eq!(result.sequence, b"CATCATCAT");
}
#[test]
fn fn_consensus_empty_input() {
let result = poa_consensus::consensus(&[], 0, &PoaConfig::default());
assert!(matches!(result, Err(PoaError::EmptyInput)));
}
#[test]
fn fn_consensus_seed_out_of_bounds() {
let reads: Vec<&[u8]> = vec![b"ACGT", b"ACGT"];
let result = poa_consensus::consensus(&reads, 5, &PoaConfig::default());
assert!(matches!(
result,
Err(PoaError::SeedOutOfBounds { index: 5, len: 2 })
));
}
#[test]
fn fn_consensus_config_respected() {
let reads: Vec<&[u8]> = vec![b"CATCATCAT", b"CATCATCAT", b"CATCATCAT"];
let cfg = PoaConfig {
min_reads: 5,
..Default::default()
};
let result = poa_consensus::consensus(&reads, 0, &cfg);
assert!(matches!(result, Err(PoaError::InsufficientDepth { .. })));
}
#[test]
fn fn_consensus_multi_two_alleles() {
let allele_a: &[u8] = b"CATCATCAT";
let allele_b: &[u8] = b"CATCGTCAT";
let reads: Vec<&[u8]> = vec![
allele_a, allele_a, allele_a, allele_a, allele_b, allele_b, allele_b, allele_b,
];
let results = poa_consensus::consensus_multi(&reads, 0, &PoaConfig::default()).unwrap();
assert_eq!(results.len(), 2, "expected 2 alleles");
}
#[test]
fn fn_consensus_multi_empty_input() {
let result = poa_consensus::consensus_multi(&[], 0, &PoaConfig::default());
assert!(matches!(result, Err(PoaError::EmptyInput)));
}
#[test]
fn fn_consensus_multi_seed_out_of_bounds() {
let reads: Vec<&[u8]> = vec![b"ACGT", b"ACGT"];
let result = poa_consensus::consensus_multi(&reads, 99, &PoaConfig::default());
assert!(matches!(
result,
Err(PoaError::SeedOutOfBounds { index: 99, len: 2 })
));
}
#[test]
fn adaptive_clean_single_allele() {
let reads: Vec<&[u8]> = vec![b"CATCATCAT"; 6];
let results = poa_consensus::consensus_adaptive(&reads, 0, &PoaConfig::default()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].sequence, b"CATCATCAT");
}
#[test]
fn adaptive_snv_bubble_splits_alleles() {
let a: &[u8] = b"CATCATCAT";
let bv: &[u8] = b"CATCGTCAT";
let reads: Vec<&[u8]> = vec![a, a, a, a, bv, bv, bv, bv];
let results = poa_consensus::consensus_adaptive(&reads, 0, &PoaConfig::default()).unwrap();
assert_eq!(results.len(), 2, "expected two alleles from SNV bubble");
let seqs: Vec<&[u8]> = results.iter().map(|c| c.sequence.as_slice()).collect();
assert!(seqs.contains(&a), "allele_a not in results");
assert!(seqs.contains(&bv), "allele_b not in results");
}
#[test]
fn adaptive_noisy_tightens_coverage() {
let correct: Vec<u8> = b("CATCATCATCATCAT");
let mut r1 = correct.clone();
r1[0] = b'G';
let mut r2 = correct.clone();
r2[3] = b'G';
let mut r3 = correct.clone();
r3[6] = b'G';
let mut r4 = correct.clone();
r4[9] = b'G';
let reads: Vec<&[u8]> = vec![&correct, &correct, &correct, &correct, &r1, &r2, &r3, &r4];
let results = poa_consensus::consensus_adaptive(&reads, 0, &PoaConfig::default()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(
results[0].sequence, correct,
"noisy reads should be filtered"
);
}
#[test]
fn adaptive_partial_reads_switches_semi_global() {
let full: Vec<u8> = b("GGGCATCATCATCATAAA");
let partial: Vec<u8> = b("CATCATCAT");
let reads: Vec<&[u8]> = vec![
full.as_slice(),
full.as_slice(),
full.as_slice(),
partial.as_slice(),
partial.as_slice(),
partial.as_slice(),
];
let results = poa_consensus::consensus_adaptive(&reads, 0, &PoaConfig::default()).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn adaptive_empty_input() {
let result = poa_consensus::consensus_adaptive(&[], 0, &PoaConfig::default());
assert!(matches!(result, Err(PoaError::EmptyInput)));
}
#[test]
fn adaptive_seed_out_of_bounds() {
let reads: Vec<&[u8]> = vec![b"ACGT", b"ACGT"];
let result = poa_consensus::consensus_adaptive(&reads, 9, &PoaConfig::default());
assert!(matches!(
result,
Err(PoaError::SeedOutOfBounds { index: 9, len: 2 })
));
}
#[test]
fn reads_long_aligns_correctly() {
let long: Vec<u8> = b"A".repeat(200);
let cfg = PoaConfig {
..Default::default()
};
let mut graph = PoaGraph::new(&long, cfg).unwrap();
graph.add_read(&long).unwrap();
assert_eq!(
graph.warnings_emitted(),
0,
"no warnings expected with new aligner"
);
}
#[test]
fn multi_allele_low_per_allele_depth() {
let allele_a: &[u8] = b"CATCATCAT";
let allele_b: &[u8] = b"CATCGTCAT";
let cfg = PoaConfig {
min_reads: 4,
..Default::default()
};
let reads: Vec<&[u8]> = vec![
allele_a, allele_a, allele_a, allele_a, allele_b, allele_b, allele_b,
];
let result = poa_consensus::consensus_multi(&reads, 0, &cfg);
assert!(
matches!(result, Err(PoaError::InsufficientDepth { .. })),
"expected InsufficientDepth for minor allele group, got {:?}",
result.map(|v| v.len())
);
}
#[test]
fn structural_bubble_phasing_splits_flanked_length_variants() {
let left = b"ACGTACGT";
let right = b"TTTTGGGG";
let short_mid: Vec<u8> = b"CAT".repeat(5); let long_mid: Vec<u8> = b"CAT".repeat(10);
let mut short_read = left.to_vec();
short_read.extend_from_slice(&short_mid);
short_read.extend_from_slice(right);
let mut long_read = left.to_vec();
long_read.extend_from_slice(&long_mid);
long_read.extend_from_slice(right);
let cfg = PoaConfig {
min_reads: 3,
min_allele_freq: 0.2,
phasing_bubble_min_span: 10,
..Default::default()
};
let mut all_reads: Vec<Vec<u8>> = (0..8).map(|_| short_read.clone()).collect();
all_reads.extend((0..8).map(|_| long_read.clone()));
let refs: Vec<&[u8]> = all_reads.iter().map(Vec::as_slice).collect();
let consensuses = poa_consensus::consensus_multi(&refs, 0, &cfg).unwrap();
assert_eq!(consensuses.len(), 2, "expected two allele consensuses");
let mut lens: Vec<usize> = consensuses.iter().map(|c| c.sequence.len()).collect();
lens.sort_unstable();
let expected_short = left.len() + short_mid.len() + right.len(); let expected_long = left.len() + long_mid.len() + right.len(); assert_eq!(lens[0], expected_short, "short allele length mismatch");
assert_eq!(lens[1], expected_long, "long allele length mismatch");
}
#[test]
fn structural_bubble_phasing_preserves_minority_expansion() {
let left = b"GATTACAGATTACA";
let right = b"CATCATCATCATCA";
let normal_mid: Vec<u8> = b"AAA".repeat(5); let expanded_mid: Vec<u8> = b"AAA".repeat(15);
let mut normal = left.to_vec();
normal.extend_from_slice(&normal_mid);
normal.extend_from_slice(right);
let mut expanded = left.to_vec();
expanded.extend_from_slice(&expanded_mid);
expanded.extend_from_slice(right);
let cfg = PoaConfig {
min_reads: 3,
min_allele_freq: 0.1, phasing_bubble_min_span: 10,
..Default::default()
};
let mut all_reads: Vec<Vec<u8>> = (0..10).map(|_| normal.clone()).collect();
all_reads.extend((0..3).map(|_| expanded.clone()));
let refs: Vec<&[u8]> = all_reads.iter().map(Vec::as_slice).collect();
let consensuses = poa_consensus::consensus_multi(&refs, 0, &cfg).unwrap();
assert_eq!(
consensuses.len(),
2,
"somatic expansion must appear as a second consensus"
);
let mut lens: Vec<usize> = consensuses.iter().map(|c| c.sequence.len()).collect();
lens.sort_unstable();
let expected_normal = left.len() + normal_mid.len() + right.len();
let expected_expanded = left.len() + expanded_mid.len() + right.len();
assert_eq!(lens[0], expected_normal, "normal allele length mismatch");
assert_eq!(
lens[1], expected_expanded,
"expanded allele length mismatch"
);
}
#[test]
fn structural_bubble_phasing_sequence_agnostic() {
let left = b"GCTAGCTAGCTA";
let right = b"TAGCTAGCTAGC";
let normal_mid: &[u8] = b"";
let inserted_mid: Vec<u8> = b"AAACCCGGGTTTT".repeat(2);
let mut normal = left.to_vec();
normal.extend_from_slice(normal_mid);
normal.extend_from_slice(right);
let mut inserted = left.to_vec();
inserted.extend_from_slice(&inserted_mid);
inserted.extend_from_slice(right);
let cfg = PoaConfig {
min_reads: 3,
min_allele_freq: 0.2,
phasing_bubble_min_span: 10,
..Default::default()
};
let mut all_reads: Vec<Vec<u8>> = (0..8).map(|_| normal.clone()).collect();
all_reads.extend((0..8).map(|_| inserted.clone()));
let refs: Vec<&[u8]> = all_reads.iter().map(Vec::as_slice).collect();
let consensuses = poa_consensus::consensus_multi(&refs, 0, &cfg).unwrap();
assert_eq!(
consensuses.len(),
2,
"non-repetitive SV should split into two consensuses"
);
let mut lens: Vec<usize> = consensuses.iter().map(|c| c.sequence.len()).collect();
lens.sort_unstable();
assert_eq!(lens[0], left.len() + normal_mid.len() + right.len());
assert_eq!(lens[1], left.len() + inserted_mid.len() + right.len());
}
#[test]
fn structural_bubble_phasing_ignores_snp_bubbles() {
let allele_a: &[u8] = b"CATCATCAT";
let allele_b: &[u8] = b"CATCGTCAT";
let cfg = PoaConfig {
min_reads: 3,
min_allele_freq: 0.2,
phasing_bubble_min_span: 10, ..Default::default()
};
let reads: Vec<&[u8]> = vec![
allele_a, allele_a, allele_a, allele_a, allele_b, allele_b, allele_b, allele_b,
];
let consensuses = poa_consensus::consensus_multi(&reads, 0, &cfg).unwrap();
assert_eq!(
consensuses.len(),
2,
"SNP haplotypes should still be detected via fallback"
);
}
#[test]
fn structural_bubble_phasing_three_alleles() {
let left = b"ACGTACGTACGT";
let right = b"TTTTGGGGTTTT";
let short_mid: Vec<u8> = b"CAT".repeat(3); let medium_mid: Vec<u8> = b"CAT".repeat(8); let long_mid: Vec<u8> = b"CAT".repeat(15);
let make = |mid: &[u8]| -> Vec<u8> {
let mut r = left.to_vec();
r.extend_from_slice(mid);
r.extend_from_slice(right);
r
};
let short_read = make(&short_mid);
let medium_read = make(&medium_mid);
let long_read = make(&long_mid);
let cfg = PoaConfig {
min_reads: 3,
min_allele_freq: 0.15,
phasing_bubble_min_span: 10,
..Default::default()
};
let mut all_reads: Vec<Vec<u8>> = (0..6).map(|_| short_read.clone()).collect();
all_reads.extend((0..6).map(|_| medium_read.clone()));
all_reads.extend((0..6).map(|_| long_read.clone()));
let refs: Vec<&[u8]> = all_reads.iter().map(Vec::as_slice).collect();
let consensuses = poa_consensus::consensus_multi(&refs, 0, &cfg).unwrap();
assert_eq!(consensuses.len(), 3, "expected three allele consensuses");
let mut lens: Vec<usize> = consensuses.iter().map(|c| c.sequence.len()).collect();
lens.sort_unstable();
assert_eq!(lens[0], left.len() + short_mid.len() + right.len());
assert_eq!(lens[1], left.len() + medium_mid.len() + right.len());
assert_eq!(lens[2], left.len() + long_mid.len() + right.len());
}
#[test]
fn structural_bubble_phasing_no_spurious_split_below_threshold() {
let left = b"GATTACAGATTACA";
let right = b"CATCATCATCATCA";
let normal_mid: Vec<u8> = b"AAACCC".repeat(3); let rare_mid: Vec<u8> = b"AAACCC".repeat(8);
let make = |mid: &[u8]| -> Vec<u8> {
let mut r = left.to_vec();
r.extend_from_slice(mid);
r.extend_from_slice(right);
r
};
let normal = make(&normal_mid);
let rare = make(&rare_mid);
let cfg = PoaConfig {
min_reads: 3,
min_allele_freq: 0.2, phasing_bubble_min_span: 10,
..Default::default()
};
let mut all_reads: Vec<Vec<u8>> = (0..10).map(|_| normal.clone()).collect();
all_reads.push(rare);
let refs: Vec<&[u8]> = all_reads.iter().map(Vec::as_slice).collect();
let consensuses = poa_consensus::consensus_multi(&refs, 0, &cfg).unwrap();
assert_eq!(
consensuses.len(),
1,
"single rare read must not trigger a spurious split"
);
}
#[test]
fn diagonal_skip_rate_increases_with_read_count() {
use crate::graph::{reset_skip_counters, skip_rate};
let seq = b"ACGTACGATCGATCGTAGCTAGCTAGCTACGATCGATCGATCGTACGATCG\
TAGCTAGCTAGCATCGATCGATCGTACGATCGTAGCTAGCTAGCTACGATC";
let cfg = PoaConfig {
min_reads: 3,
..Default::default()
};
let mut graph = PoaGraph::new(seq, cfg).unwrap();
reset_skip_counters();
graph.add_read(seq).unwrap();
let rate2 = skip_rate();
reset_skip_counters();
graph.add_read(seq).unwrap();
let rate3 = skip_rate();
reset_skip_counters();
graph.add_read(seq).unwrap();
let rate4 = skip_rate();
reset_skip_counters();
graph.add_read(seq).unwrap();
let rate5 = skip_rate();
eprintln!(
"diagonal skip rates — r2: {:.2}% r3: {:.2}% r4: {:.2}% r5: {:.2}%",
rate2 * 100.0,
rate3 * 100.0,
rate4 * 100.0,
rate5 * 100.0,
);
assert!(
rate3 >= rate2,
"skip rate should not decrease: r3={:.2}% r2={:.2}%",
rate3 * 100.0,
rate2 * 100.0,
);
assert!(
rate5 >= rate4,
"skip rate should not decrease: r5={:.2}% r4={:.2}%",
rate5 * 100.0,
rate4 * 100.0,
);
assert!(
rate5 > 0.5,
"expected >50% skip rate for identical reads by read 5, got {:.2}%",
rate5 * 100.0,
);
}
#[test]
fn stale_spine_same_consensus_as_fresh() {
let base: &[u8] = b"ACGATCGATCGATCGTAGCTAGCTAGCTACGATCGATCGATCGTACGATCG\
TAGCTAGCTAGCATCGATCGATCGTACGATCGTAGCTAGCTAGCTACGATC";
let mut reads: Vec<Vec<u8>> = (0..18).map(|_| base.to_vec()).collect();
let mut r1 = base.to_vec();
r1[10] = b'T'; let mut r2 = base.to_vec();
r2[40] = b'G'; reads.push(r1);
reads.push(r2);
let cfg = PoaConfig {
min_reads: 3,
..Default::default()
};
let stale_result = poa_consensus::consensus(
&reads.iter().map(|r| r.as_slice()).collect::<Vec<_>>(),
0,
&cfg,
)
.unwrap();
let ref_result = poa_consensus::consensus(
&reads.iter().map(|r| r.as_slice()).collect::<Vec<_>>(),
0,
&cfg,
)
.unwrap();
assert_eq!(
stale_result.sequence, ref_result.sequence,
"stale-spine consensus differs from reference"
);
assert_eq!(
stale_result.sequence,
base.to_vec(),
"consensus should match the dominant base sequence"
);
}
#[test]
fn locked_arm_deep_bubble_alleles_lost() {
let g_allele: Vec<u8> = [b"CCCCCCCCCC".as_slice(), &b"G".repeat(30), b"TTTTTTTTTT"].concat();
let a_allele: Vec<u8> = [b"CCCCCCCCCC".as_slice(), &b"A".repeat(30), b"TTTTTTTTTT"].concat();
let mut reads: Vec<Vec<u8>> = std::iter::repeat(g_allele.clone()).take(4).collect();
reads.extend(std::iter::repeat(a_allele.clone()).take(4));
let refs: Vec<&[u8]> = reads.iter().map(Vec::as_slice).collect();
let cfg = PoaConfig {
min_reads: 3,
adaptive_band: true,
..Default::default()
};
let result = poa_consensus::consensus_multi(&refs, 0, &cfg).unwrap();
let mut seqs: Vec<Vec<u8>> = result.iter().map(|c| c.sequence.clone()).collect();
seqs.sort_unstable();
let mut expected = vec![g_allele.clone(), a_allele.clone()];
expected.sort_unstable();
assert_eq!(
seqs,
expected,
"deep arm: allele recovery failed.\n got: {:?}\n expected: {:?}",
seqs.iter()
.map(|s| String::from_utf8_lossy(s).to_string())
.collect::<Vec<_>>(),
expected
.iter()
.map(|s| String::from_utf8_lossy(s).to_string())
.collect::<Vec<_>>(),
);
}