#![allow(clippy::similar_names, clippy::cast_precision_loss)]
mod helpers;
use helpers::{
TestEnv, VcfVariant, methylate_to_vcf, methylate_to_vcf_with_variants, read_bam_records,
read_gzipped, run_simulate,
};
fn cytosine_rich_env() -> TestEnv {
let seq = b"ACGT".repeat(250); TestEnv::new(&[("chr1", &seq)])
}
fn count_c_and_t(fastq_text: &str) -> (usize, usize) {
let mut c = 0;
let mut t = 0;
for (i, line) in fastq_text.lines().enumerate() {
if i % 4 == 1 {
for &b in line.as_bytes() {
match b {
b'C' => c += 1,
b'T' => t += 1,
_ => {}
}
}
}
}
(c, t)
}
#[expect(clippy::naive_bytecount, reason = "test scope; clarity over speed")]
fn count_base(fastq_text: &str, target: u8) -> usize {
let mut n = 0;
for (i, line) in fastq_text.lines().enumerate() {
if i % 4 == 1 {
n += line.as_bytes().iter().filter(|&&b| b == target).count();
}
}
n
}
#[test]
fn test_em_seq_full_conversion_eliminates_c() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let r2 = read_gzipped(&out.with_extension("r2.fastq.gz"));
let (c1, t1) = count_c_and_t(&r1);
let (_c2, t2) = count_c_and_t(&r2);
let g2 = count_base(&r2, b'G');
assert_eq!(c1, 0, "R1 still contains C bases: {c1}");
assert_eq!(
g2, 0,
"R2 still contains G bases: {g2} — c2t(source) has no C's so revcomp has no G's"
);
assert!(t1 > 0, "R1 should have many T's after conversion");
assert!(t2 > 0, "R2 should have many T's");
}
#[test]
fn test_em_seq_full_methylation_preserves_c() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let (c1, t1) = count_c_and_t(&r1);
assert!(c1 > 0, "R1 should still contain C's when fully methylated");
let ratio = c1 as f64 / t1 as f64;
assert!(
(0.7..1.4).contains(&ratio),
"C:T ratio {ratio:.2} suggests unintended conversion (c={c1}, t={t1})"
);
}
#[test]
fn test_em_seq_partial_conversion_rate() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"20",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"0.5",
"--methylation-failure-rate",
"0.0",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let (c1, t1) = count_c_and_t(&r1);
let ratio = c1 as f64 / (c1 + t1) as f64;
assert!(
(0.20..0.30).contains(&ratio),
"expected C / (C+T) ≈ 0.25 with 50% conversion; got {ratio:.3} (c={c1}, t={t1})"
);
}
#[test]
fn test_em_seq_intermediate_methylation_preserves_some_c() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"30",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let (c1, _t1) = count_c_and_t(&r1);
assert!(c1 > 0, "expected C's preserved at methylated CpG sites; got 0");
}
#[test]
fn test_methylation_is_deterministic_with_seed() {
let env = cytosine_rich_env();
let out_a = env.dir.path().join("a");
let out_b = env.dir.path().join("b");
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.7, 12345, "meth.vcf.gz");
let common: Vec<&str> = vec![
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"0.5",
"--methylation-failure-rate",
"0.0",
"--seed",
"12345",
"--threads",
"1",
];
let mut a_args = common.clone();
a_args.push("-o");
a_args.push(out_a.to_str().unwrap());
let (ok_a, _, err_a) = run_simulate(&a_args);
assert!(ok_a, "first run failed: {err_a}");
let mut b_args = common.clone();
b_args.push("-o");
b_args.push(out_b.to_str().unwrap());
let (ok_b, _, err_b) = run_simulate(&b_args);
assert!(ok_b, "second run failed: {err_b}");
let r1_a = read_gzipped(&out_a.with_extension("r1.fastq.gz"));
let r1_b = read_gzipped(&out_b.with_extension("r1.fastq.gz"));
assert_eq!(r1_a, r1_b, "methylation output must be reproducible with --seed");
}
use noodles::sam::alignment::record::data::field::Tag as DataTag;
use noodles::sam::alignment::record_buf::data::field::Value as DataValue;
#[test]
fn test_golden_bam_em_seq_emits_xg_xr_and_ys_tags_when_enabled() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let bam_path = out.with_extension("golden.bam");
let records = read_bam_records(&bam_path);
assert!(!records.is_empty(), "expected non-empty golden BAM");
let xg = DataTag::new(b'X', b'G');
let xr = DataTag::new(b'X', b'R');
let yc = DataTag::new(b'Y', b'C');
let ys = DataTag::new(b'Y', b'S');
for rec in &records {
let data = rec.data();
assert!(data.get(&yc).is_none(), "YC:Z must not be present (replaced by XG:Z)");
let xg_val = data.get(&xg).expect("XG:Z must be present");
let xg_str = match xg_val {
DataValue::String(s) => std::str::from_utf8(s.as_ref()).unwrap().to_string(),
other => panic!("XG must be a String tag, got {other:?}"),
};
assert!(xg_str == "CT" || xg_str == "GA", "XG:Z must be 'CT' or 'GA', got {xg_str:?}");
let is_rev = rec.flags().is_reverse_complemented();
let is_r1 = rec.flags().is_first_segment();
let expected_xg = match (is_r1, is_rev) {
(true, false) | (false, true) => "CT",
(true, true) | (false, false) => "GA",
};
assert_eq!(
xg_str, expected_xg,
"expected XG={expected_xg} for is_r1={is_r1}, is_reverse={is_rev}; got {xg_str}"
);
let xr_val = data.get(&xr).expect("XR:Z must be present");
let xr_str = match xr_val {
DataValue::String(s) => std::str::from_utf8(s.as_ref()).unwrap().to_string(),
other => panic!("XR must be a String tag, got {other:?}"),
};
let expected_xr =
if rec.flags().is_segmented() && rec.flags().is_last_segment() { "GA" } else { "CT" };
assert_eq!(
xr_str, expected_xr,
"expected XR={expected_xr} for is_r1={is_r1}; got {xr_str}"
);
let ys_val = data.get(&ys).expect("YS:Z must be present");
match ys_val {
DataValue::String(s) => {
let bytes: &[u8] = s.as_ref();
assert_eq!(
bytes.len(),
rec.sequence().as_ref().len(),
"YS length must match SEQ length"
);
}
other => panic!("YS must be a String tag, got {other:?}"),
}
}
}
#[test]
fn test_golden_bam_omits_methylation_tags_when_methylation_disabled() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let bam_path = out.with_extension("golden.bam");
let records = read_bam_records(&bam_path);
assert!(!records.is_empty());
let xg = DataTag::new(b'X', b'G');
let xr = DataTag::new(b'X', b'R');
let yc = DataTag::new(b'Y', b'C');
let ys = DataTag::new(b'Y', b'S');
let cf = DataTag::new(b'c', b'f');
for rec in &records {
assert!(rec.data().get(&xg).is_none(), "XG must not be present without methylation");
assert!(rec.data().get(&xr).is_none(), "XR must not be present without methylation");
assert!(rec.data().get(&yc).is_none(), "YC must never be present (removed)");
assert!(rec.data().get(&ys).is_none(), "YS must not be present without methylation");
assert!(rec.data().get(&cf).is_none(), "cf must not be present without methylation");
}
}
#[test]
fn test_golden_bam_em_seq_ys_matches_reference_oriented_pre_conversion() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"10",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let bam_path = out.with_extension("golden.bam");
let records = read_bam_records(&bam_path);
let ys_tag = DataTag::new(b'Y', b'S');
let xg_tag = DataTag::new(b'X', b'G');
let mut saw_difference = false;
for rec in &records {
let seq: Vec<u8> = rec.sequence().as_ref().to_vec();
let DataValue::String(ys) = rec.data().get(&ys_tag).expect("YS present") else {
panic!("YS must be string");
};
let DataValue::String(xg) = rec.data().get(&xg_tag).expect("XG present") else {
panic!("XG must be string");
};
let ys_bytes: &[u8] = ys.as_ref();
let xg_bytes: &[u8] = xg.as_ref();
if seq != ys_bytes {
saw_difference = true;
for (s, y) in seq.iter().zip(ys_bytes.iter()) {
if s != y {
match xg_bytes {
b"CT" => assert_eq!(
(*s, *y),
(b'T', b'C'),
"XG=CT diff must be T/C; got SEQ={} YS={}",
*s as char,
*y as char
),
b"GA" => assert_eq!(
(*s, *y),
(b'A', b'G'),
"XG=GA diff must be A/G; got SEQ={} YS={}",
*s as char,
*y as char
),
other => panic!("unexpected XG tag value: {other:?}"),
}
}
}
}
}
assert!(saw_difference, "expected at least one record where SEQ != YS after conversion");
}
#[test]
fn test_em_seq_single_end_emits_xg_ys_and_full_conversion() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--single-end",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let (c1, t1) = count_c_and_t(&r1);
assert_eq!(c1, 0, "SE R1 should have no C's after full conversion");
assert!(t1 > 0, "SE R1 should have many T's after conversion");
let r2_path = out.with_extension("r2.fastq.gz");
assert!(!r2_path.exists(), "SE mode must not create an R2 file; found {}", r2_path.display());
let bam_path = out.with_extension("golden.bam");
let records = read_bam_records(&bam_path);
assert!(!records.is_empty(), "expected non-empty SE golden BAM");
let xg = DataTag::new(b'X', b'G');
let xr = DataTag::new(b'X', b'R');
let ys = DataTag::new(b'Y', b'S');
let cf = DataTag::new(b'c', b'f');
for rec in &records {
assert!(!rec.flags().is_segmented(), "SE record must not have SEGMENTED flag set");
let cf_val =
rec.data().get(&cf).expect("cf:i must be present").as_int().expect("cf is int");
assert_eq!(cf_val, 0, "SE record at failure-rate 0.0 must be cf:i:0");
let xg_val = rec.data().get(&xg).expect("XG:Z must be present");
let DataValue::String(s) = xg_val else {
panic!("XG must be a String tag");
};
let s = std::str::from_utf8(s.as_ref()).unwrap();
let is_rev = rec.flags().is_reverse_complemented();
let expected = if is_rev { "GA" } else { "CT" };
assert_eq!(s, expected, "SE record XG must match strand: is_reverse={is_rev}, got {s}");
let xr_val = rec.data().get(&xr).expect("XR:Z must be present");
let DataValue::String(xr_bstring) = xr_val else {
panic!("XR must be a String tag");
};
let xr_bytes: &[u8] = xr_bstring.as_ref();
assert_eq!(xr_bytes, b"CT", "SE XR:Z must be 'CT' (R1/SE convention)");
let ys_val = rec.data().get(&ys).expect("YS:Z must be present");
let DataValue::String(ys_bstring) = ys_val else {
panic!("YS must be a String tag");
};
let ys_bytes: &[u8] = ys_bstring.as_ref();
assert_eq!(
ys_bytes.len(),
rec.sequence().as_ref().len(),
"YS length must match SEQ length"
);
}
}
#[test]
fn test_taps_full_methylation_full_conversion_eliminates_c_at_cpg() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"taps",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let r2 = read_gzipped(&out.with_extension("r2.fastq.gz"));
let (c1, t1) = count_c_and_t(&r1);
let (_c2, t2) = count_c_and_t(&r2);
let g2 = count_base(&r2, b'G');
assert_eq!(c1, 0, "TAPS R1 still contains C bases: {c1}");
assert_eq!(g2, 0, "TAPS R2 still contains G bases: {g2}");
assert!(t1 > 0, "TAPS R1 should have many T's after conversion");
assert!(t2 > 0, "TAPS R2 should have many T's after conversion");
}
#[test]
fn test_taps_zero_methylation_preserves_all_c() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"taps",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let (c1, t1) = count_c_and_t(&r1);
assert!(c1 > 0, "TAPS without methylation must preserve C's, got 0");
let ratio = c1 as f64 / t1 as f64;
assert!(
(0.7..1.4).contains(&ratio),
"TAPS C:T ratio {ratio:.2} suggests unintended conversion (c={c1}, t={t1})"
);
}
#[test]
fn test_taps_golden_bam_emits_xg_xr_and_ys() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"taps",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let bam_path = out.with_extension("golden.bam");
let records = read_bam_records(&bam_path);
assert!(!records.is_empty(), "expected non-empty TAPS golden BAM");
let xg = DataTag::new(b'X', b'G');
let xr = DataTag::new(b'X', b'R');
let ys = DataTag::new(b'Y', b'S');
for rec in &records {
let xg_val = rec.data().get(&xg).expect("TAPS XG:Z must be present");
let DataValue::String(xg_bstring) = xg_val else {
panic!("XG must be a String tag");
};
let xg_str = std::str::from_utf8(xg_bstring.as_ref()).unwrap();
assert!(xg_str == "CT" || xg_str == "GA", "TAPS XG must be 'CT' or 'GA', got {xg_str:?}");
let xr_val = rec.data().get(&xr).expect("TAPS XR:Z must be present");
let DataValue::String(xr_bstring) = xr_val else {
panic!("XR must be a String tag");
};
let xr_str = std::str::from_utf8(xr_bstring.as_ref()).unwrap();
let expected_xr =
if rec.flags().is_segmented() && rec.flags().is_last_segment() { "GA" } else { "CT" };
assert_eq!(xr_str, expected_xr, "TAPS XR:Z mismatch (R1/SE → CT, R2 → GA)");
let ys_val = rec.data().get(&ys).expect("TAPS YS:Z must be present");
let DataValue::String(ys_bstring) = ys_val else {
panic!("YS must be a String tag");
};
let ys_bytes: &[u8] = ys_bstring.as_ref();
assert_eq!(
ys_bytes.len(),
rec.sequence().as_ref().len(),
"TAPS YS length must match SEQ length"
);
}
}
#[test]
fn test_em_seq_and_taps_produce_different_output() {
let env = cytosine_rich_env();
let out_em = env.dir.path().join("em");
let out_taps = env.dir.path().join("taps");
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.5, 9876, "meth.vcf.gz");
let common: Vec<&str> = vec![
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--seed",
"9876",
"--threads",
"1",
];
let mut em_args = common.clone();
em_args.extend_from_slice(&["-o", out_em.to_str().unwrap(), "--methylation-mode", "em-seq"]);
let (ok_em, _, err_em) = run_simulate(&em_args);
assert!(ok_em, "em-seq run failed: {err_em}");
let mut taps_args = common.clone();
taps_args.extend_from_slice(&["-o", out_taps.to_str().unwrap(), "--methylation-mode", "taps"]);
let (ok_taps, _, err_taps) = run_simulate(&taps_args);
assert!(ok_taps, "taps run failed: {err_taps}");
let r1_em = read_gzipped(&out_em.with_extension("r1.fastq.gz"));
let r1_taps = read_gzipped(&out_taps.with_extension("r1.fastq.gz"));
assert_ne!(r1_em, r1_taps, "em-seq and TAPS must produce different output with the same seed");
}
#[test]
fn test_em_seq_xg_pair_level_consistency() {
use std::collections::HashMap;
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.5, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"20",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--golden-bam",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
let xg_tag = DataTag::new(b'X', b'G');
let mut by_name: HashMap<String, Vec<String>> = HashMap::new();
for rec in &records {
let DataValue::String(s) = rec.data().get(&xg_tag).expect("XG present") else {
panic!("XG must be string");
};
let xg = std::str::from_utf8(s.as_ref()).unwrap().to_string();
let name =
std::str::from_utf8(rec.name().expect("name present").as_ref()).unwrap().to_string();
by_name.entry(name).or_default().push(xg);
}
assert!(!by_name.is_empty(), "expected at least one read pair");
let mut pair_count = 0;
for (name, xgs) in &by_name {
assert_eq!(xgs.len(), 2, "expected exactly 2 records for read name {name}, got {xgs:?}");
assert_eq!(
xgs[0], xgs[1],
"R1 and R2 of pair {name} carry inconsistent XG values: {xgs:?}"
);
pair_count += 1;
}
assert!(pair_count >= 50, "expected ~200 pairs, got {pair_count}");
}
#[test]
fn test_em_seq_xg_distribution_covers_all_four_cells() {
use std::collections::HashMap;
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.5, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"30",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--golden-bam",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
let xg_tag = DataTag::new(b'X', b'G');
let mut counts: HashMap<(bool, bool, String), u32> = HashMap::new();
for rec in &records {
let DataValue::String(s) = rec.data().get(&xg_tag).expect("XG present") else {
panic!("XG must be string");
};
let xg = std::str::from_utf8(s.as_ref()).unwrap().to_string();
let is_r1 = rec.flags().is_first_segment();
let is_rev = rec.flags().is_reverse_complemented();
*counts.entry((is_r1, is_rev, xg)).or_insert(0) += 1;
}
let valid_cells = [
(true, false, "CT".to_string()),
(false, true, "CT".to_string()),
(true, true, "GA".to_string()),
(false, false, "GA".to_string()),
];
for cell in &valid_cells {
let n = counts.get(cell).copied().unwrap_or(0);
assert!(
n >= 10,
"cell {cell:?} should have >= 10 records, got {n}; full counts: {counts:?}"
);
}
for (cell, n) in &counts {
assert!(valid_cells.contains(cell), "unexpected XG × strand cell {cell:?} with count {n}");
}
}
#[test]
fn test_bisulfite_alias_byte_identical_to_em_seq() {
let env = cytosine_rich_env();
let out_em = env.dir.path().join("em");
let out_bs = env.dir.path().join("bs");
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 12345, "meth.vcf.gz");
let common: Vec<&str> = vec![
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--seed",
"12345",
"--threads",
"1",
];
let mut em_args = common.clone();
em_args.push("--methylation-mode");
em_args.push("em-seq");
em_args.push("-o");
em_args.push(out_em.to_str().unwrap());
let (ok_em, _, err_em) = run_simulate(&em_args);
assert!(ok_em, "em-seq run failed: {err_em}");
let mut bs_args = common.clone();
bs_args.push("--methylation-mode");
bs_args.push("bisulfite");
bs_args.push("-o");
bs_args.push(out_bs.to_str().unwrap());
let (ok_bs, _, err_bs) = run_simulate(&bs_args);
assert!(ok_bs, "bisulfite-alias run failed: {err_bs}");
let r1_em = read_gzipped(&out_em.with_extension("r1.fastq.gz"));
let r1_bs = read_gzipped(&out_bs.with_extension("r1.fastq.gz"));
assert_eq!(r1_em, r1_bs, "`bisulfite` alias must produce byte-identical output to `em-seq`");
}
#[test]
fn test_unknown_methylation_mode_rejected() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--methylation-mode",
"wgbs",
"--seed",
"42",
"--threads",
"1",
]);
assert!(!ok, "expected simulate to fail with unknown mode 'wgbs'");
assert!(
stderr.contains("invalid value 'wgbs'"),
"expected clap value-enum error, got stderr: {stderr}"
);
assert!(
stderr.contains("em-seq") && stderr.contains("taps"),
"expected error to list valid modes, got stderr: {stderr}"
);
}
#[test]
fn test_taps_golden_bam_ys_diffs_match_chemistry() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"10",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"taps",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
let ys_tag = DataTag::new(b'Y', b'S');
let xg_tag = DataTag::new(b'X', b'G');
let mut saw_difference = false;
for rec in &records {
let seq: Vec<u8> = rec.sequence().as_ref().to_vec();
let DataValue::String(ys) = rec.data().get(&ys_tag).expect("YS present") else {
panic!("YS must be string");
};
let DataValue::String(xg) = rec.data().get(&xg_tag).expect("XG present") else {
panic!("XG must be string");
};
let ys_bytes: &[u8] = ys.as_ref();
let xg_bytes: &[u8] = xg.as_ref();
if seq != ys_bytes {
saw_difference = true;
for (s, y) in seq.iter().zip(ys_bytes.iter()) {
if s != y {
match xg_bytes {
b"CT" => assert_eq!(
(*s, *y),
(b'T', b'C'),
"TAPS XG=CT diff must be T/C; got SEQ={} YS={}",
*s as char,
*y as char
),
b"GA" => assert_eq!(
(*s, *y),
(b'A', b'G'),
"TAPS XG=GA diff must be A/G; got SEQ={} YS={}",
*s as char,
*y as char
),
other => panic!("unexpected XG tag value: {other:?}"),
}
}
}
}
}
assert!(saw_difference, "expected at least one TAPS record where SEQ != YS after conversion");
}
fn mixed_context_env() -> TestEnv {
let mut seq = Vec::with_capacity(1000);
seq.extend(b"ACGT".repeat(125)); seq.extend(b"AAAACAAA".repeat(63)); seq.truncate(1000);
TestEnv::new(&[("chr1", &seq)])
}
#[test]
fn test_em_seq_full_cpg_methylation_still_converts_non_cpg() {
let env = mixed_context_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"30",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
let (c1, t1) = count_c_and_t(&r1);
assert!(c1 > 0, "CpG-protected C's should remain (got 0)");
assert!(t1 > 0, "non-CpG C's should have converted to T (got 0)");
let ratio = c1 as f64 / (c1 + t1) as f64;
assert!(
(0.20..0.30).contains(&ratio),
"expected C / (C+T) ≈ 0.25 with half-CpG-half-non-CpG ref; got {ratio:.3} (c={c1}, t={t1})"
);
}
#[test]
fn test_em_seq_with_vcf_handles_haplotype_specific_cpgs() {
let mut seq = vec![b'A'; 200];
seq[6] = b'G'; let env = TestEnv::new(&[("chr1", &seq)]);
let variants_vcf = env.write_vcf(
"sample1",
&[("chr1", 200)],
&[VcfVariant {
chrom: "chr1",
pos_1based: 6,
ref_allele: "A",
alt_alleles: &["C"],
gt: "0|1",
}],
);
let meth_vcf = methylate_to_vcf_with_variants(
&env,
&env.fasta_path,
Some(&variants_vcf),
1.0,
42,
"meth.vcf.gz",
);
let out = env.output_prefix();
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
meth_vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"200",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
assert!(!records.is_empty(), "expected non-empty golden BAM with VCF + methylation");
let hp_tag = DataTag::new(b'h', b'p');
let mut saw_variant_hap_cpg_preserved = false;
for rec in &records {
let hp_val = rec.data().get(&hp_tag);
let is_variant_hap = match hp_val {
Some(DataValue::Int8(v)) => *v == 1,
Some(DataValue::UInt8(v)) => *v == 1,
Some(DataValue::Int16(v)) => *v == 1,
Some(DataValue::UInt16(v)) => *v == 1,
Some(DataValue::Int32(v)) => *v == 1,
Some(DataValue::UInt32(v)) => *v == 1,
_ => false,
};
if !is_variant_hap {
continue;
}
let Some(start_1based) = rec.alignment_start() else { continue };
let start_0based = usize::from(start_1based) - 1;
let seq: Vec<u8> = rec.sequence().as_ref().to_vec();
let end_excl = start_0based + seq.len();
if start_0based > 5 || end_excl <= 5 {
continue;
}
let offset_in_seq = 5 - start_0based;
let seq_byte = seq[offset_in_seq];
if seq_byte == b'C' {
saw_variant_hap_cpg_preserved = true;
break;
}
}
assert!(
saw_variant_hap_cpg_preserved,
"expected at least one variant-haplotype record where the SNP-created CpG C was preserved; \
per-haplotype methylation regression"
);
}
fn hp_tag_is_one(value: Option<&DataValue>) -> bool {
matches!(
value,
Some(
DataValue::Int8(1)
| DataValue::UInt8(1)
| DataValue::Int16(1)
| DataValue::UInt16(1)
| DataValue::Int32(1)
| DataValue::UInt32(1)
)
)
}
#[test]
fn test_taps_with_vcf_converts_methylated_haplotype_specific_cpg() {
let mut seq = vec![b'A'; 200];
seq[6] = b'G';
let env = TestEnv::new(&[("chr1", &seq)]);
let variants_vcf = env.write_vcf(
"sample1",
&[("chr1", 200)],
&[VcfVariant {
chrom: "chr1",
pos_1based: 6,
ref_allele: "A",
alt_alleles: &["C"],
gt: "0|1",
}],
);
let meth_vcf = methylate_to_vcf_with_variants(
&env,
&env.fasta_path,
Some(&variants_vcf),
1.0,
42,
"meth.vcf.gz",
);
let out = env.output_prefix();
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
meth_vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"200",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"taps",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
assert!(!records.is_empty(), "expected non-empty golden BAM with VCF + methylation");
let hp_tag = DataTag::new(b'h', b'p');
let ys_tag = DataTag::new(b'Y', b'S');
let mut saw_variant_hap_cpg_converted = false;
for rec in &records {
if rec.flags().is_reverse_complemented() || !hp_tag_is_one(rec.data().get(&hp_tag)) {
continue;
}
let Some(start_1based) = rec.alignment_start() else { continue };
let start_0based = usize::from(start_1based) - 1;
let seq_bytes: Vec<u8> = rec.sequence().as_ref().to_vec();
if start_0based > 5 || start_0based + seq_bytes.len() <= 5 {
continue;
}
let offset = 5 - start_0based;
let DataValue::String(ys) = rec.data().get(&ys_tag).expect("YS:Z must be present") else {
panic!("YS must be a String tag");
};
let ys_slice: &[u8] = ys.as_ref();
assert_eq!(
ys_slice[offset], b'C',
"pre-conversion YS must show the SNP-created CpG C on the variant haplotype"
);
if seq_bytes[offset] == b'T' {
saw_variant_hap_cpg_converted = true;
break;
}
}
assert!(
saw_variant_hap_cpg_converted,
"expected a forward variant-haplotype record where the methylated CpG C was converted to T \
under TAPS; inverse-chemistry regression"
);
}
#[test]
fn test_cpg_truth_bedgraph_requires_methylation_mode() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let bg = env.dir.path().join("truth.bedGraph");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--cpg-truth-bedgraph",
bg.to_str().unwrap(),
"--seed",
"42",
"--threads",
"1",
]);
assert!(!ok, "--cpg-truth-bedgraph without --methylation-mode must be rejected");
assert!(
stderr.contains("--cpg-truth-bedgraph requires --methylation-mode"),
"expected validation message, got stderr: {stderr}"
);
}
#[test]
fn test_header_only_mt_mb_vcf_is_rejected() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf_path = env.dir.path().join("header_only.vcf");
std::fs::write(
&vcf_path,
"##fileformat=VCFv4.4\n\
##contig=<ID=chr1,length=1000>\n\
##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tMETHYLATE\n",
)
.unwrap();
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf_path.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--methylation-mode",
"em-seq",
"--seed",
"42",
"--threads",
"1",
]);
assert!(!ok, "header-only MT/MB VCF must be rejected by --methylation-mode");
assert!(
stderr.contains("requires a methylation-annotated VCF"),
"expected the MT/MB validation message, got stderr: {stderr}"
);
}
#[test]
fn test_cpg_truth_bedgraph_full_methylation_emits_only_methylated_calls() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let bg = env.dir.path().join("truth.bedGraph");
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--cpg-truth-bedgraph",
bg.to_str().unwrap(),
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let body = std::fs::read_to_string(&bg).expect("bedGraph must exist");
let lines: Vec<&str> = body.lines().collect();
assert!(
lines.first().is_some_and(|l| l.starts_with("track ")),
"first line must be a track header"
);
let mut sites = 0;
for row in &lines[1..] {
let fields: Vec<&str> = row.split('\t').collect();
assert_eq!(fields.len(), 6, "expected 6 columns, got: {row:?}");
let n_meth: u32 = fields[4].parse().unwrap();
let n_unmeth: u32 = fields[5].parse().unwrap();
let rate: u32 = fields[3].parse().unwrap();
let total = n_meth + n_unmeth;
assert!(total > 0, "row with zero coverage must not be emitted: {row:?}");
assert_eq!(n_unmeth, 0, "row {row:?} has unmethylated calls under full methylation");
assert_eq!(rate, 100, "row {row:?} should have rate 100");
let start: u32 = fields[1].parse().unwrap();
let end: u32 = fields[2].parse().unwrap();
assert_eq!(start % 4, 1, "CpG top-C must lie at a 4k+1 offset, got {start}");
assert_eq!(end, start + 1, "end column must equal start + 1");
sites += 1;
}
assert!(sites > 0, "expected at least one CpG site in bedGraph");
}
#[test]
fn test_cpg_truth_bedgraph_zero_methylation_emits_only_unmethylated_calls() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let bg = env.dir.path().join("truth.bedGraph");
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--cpg-truth-bedgraph",
bg.to_str().unwrap(),
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let body = std::fs::read_to_string(&bg).expect("bedGraph must exist");
let mut sites = 0;
for row in body.lines().skip(1) {
let fields: Vec<&str> = row.split('\t').collect();
let n_meth: u32 = fields[4].parse().unwrap();
let n_unmeth: u32 = fields[5].parse().unwrap();
let rate: u32 = fields[3].parse().unwrap();
assert_eq!(n_meth, 0, "row {row:?} has methylated calls under zero methylation");
assert!(n_unmeth > 0);
assert_eq!(rate, 0);
sites += 1;
}
assert!(sites > 0);
}
#[test]
fn test_cpg_truth_bedgraph_taps_intermediate_methylation_yields_mixed_rates() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let bg = env.dir.path().join("truth.bedGraph");
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.5, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"10",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--methylation-mode",
"taps",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--cpg-truth-bedgraph",
bg.to_str().unwrap(),
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let body = std::fs::read_to_string(&bg).expect("bedGraph must exist");
let mut total_meth: u64 = 0;
let mut total_unmeth: u64 = 0;
for row in body.lines().skip(1) {
let fields: Vec<&str> = row.split('\t').collect();
let n_meth: u32 = fields[4].parse().unwrap();
let n_unmeth: u32 = fields[5].parse().unwrap();
total_meth += u64::from(n_meth);
total_unmeth += u64::from(n_unmeth);
}
let total = total_meth + total_unmeth;
assert!(total > 0);
let frac_meth = total_meth as f64 / total as f64;
assert!(
(0.40..0.60).contains(&frac_meth),
"expected ~50% methylated truth calls at rate=0.5, got {frac_meth:.3} (m={total_meth} u={total_unmeth})"
);
}
#[test]
fn test_golden_bam_emits_xm_ym_nm_md_under_full_methylation_no_errors() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
assert!(!records.is_empty(), "expected non-empty golden BAM");
let xm_tag = DataTag::new(b'X', b'M');
let ym_tag = DataTag::new(b'Y', b'M');
let nm_tag = DataTag::new(b'N', b'M');
let md_tag = DataTag::new(b'M', b'D');
for rec in &records {
let seq_len = rec.sequence().as_ref().len();
let DataValue::String(xm) = rec.data().get(&xm_tag).expect("XM:Z must be present") else {
panic!("XM must be String");
};
let xm_bytes: &[u8] = xm.as_ref();
assert_eq!(xm_bytes.len(), seq_len, "XM length must match SEQ length");
let DataValue::String(ym) = rec.data().get(&ym_tag).expect("YM:Z must be present") else {
panic!("YM must be String");
};
let ym_bytes: &[u8] = ym.as_ref();
assert_eq!(ym_bytes.len(), seq_len, "YM length must match SEQ length");
assert_eq!(xm_bytes, ym_bytes, "XM must equal YM under full methylation, zero errors");
#[expect(clippy::naive_bytecount, reason = "test scope; clarity over speed")]
let n_methylated = xm_bytes.iter().filter(|&&b| b == b'Z').count();
assert!(n_methylated > 0, "expected >= 1 methylated CpG call per record");
for &c in xm_bytes {
assert!(
matches!(c, b'.' | b'Z'),
"unexpected XM char {c:?} under full methylation; only '.' and 'Z' allowed"
);
}
let nm_val =
rec.data().get(&nm_tag).expect("NM:i must be present").as_int().expect("NM is int");
assert_eq!(nm_val, 0, "NM must be 0 with full methylation and no errors");
let DataValue::String(md) = rec.data().get(&md_tag).expect("MD:Z must be present") else {
panic!("MD must be String");
};
let md_bytes: &[u8] = md.as_ref();
let expected = seq_len.to_string();
assert_eq!(md_bytes, expected.as_bytes(), "MD must be \"{expected}\"");
}
}
#[test]
fn test_golden_bam_xm_lowercase_under_full_conversion() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
let xm_tag = DataTag::new(b'X', b'M');
let nm_tag = DataTag::new(b'N', b'M');
for rec in &records {
let DataValue::String(xm) = rec.data().get(&xm_tag).expect("XM present") else {
panic!("XM must be String")
};
let xm_bytes: &[u8] = xm.as_ref();
#[expect(clippy::naive_bytecount, reason = "test scope; clarity over speed")]
let n_unmeth = xm_bytes.iter().filter(|&&b| b == b'z').count();
assert!(n_unmeth > 0, "expected >= 1 unmethylated 'z' per record");
for &c in xm_bytes {
assert!(matches!(c, b'.' | b'z'), "unexpected XM char {c:?}");
}
let nm_val = rec.data().get(&nm_tag).expect("NM present").as_int().expect("NM is int");
assert_eq!(nm_val, 0, "every C→T is bisulfite-allowed under XG=CT, no real mismatches");
}
}
#[test]
fn test_golden_bam_omits_xm_ym_nm_md_without_methylation() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
let xm = DataTag::new(b'X', b'M');
let ym = DataTag::new(b'Y', b'M');
let nm = DataTag::new(b'N', b'M');
let md = DataTag::new(b'M', b'D');
for rec in &records {
assert!(rec.data().get(&xm).is_none(), "XM must be absent without methylation");
assert!(rec.data().get(&ym).is_none(), "YM must be absent without methylation");
assert!(rec.data().get(&nm).is_none(), "NM must be absent without methylation");
assert!(rec.data().get(&md).is_none(), "MD must be absent without methylation");
}
}
#[test]
fn test_xm_ym_diverge_under_errors_at_methylated_cpg() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"30",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0.10",
"--max-error-rate",
"0.10",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--seed",
"42",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
let xm_tag = DataTag::new(b'X', b'M');
let ym_tag = DataTag::new(b'Y', b'M');
let mut saw_divergence = false;
for rec in &records {
let DataValue::String(xm) = rec.data().get(&xm_tag).expect("XM present") else {
panic!("XM must be String")
};
let DataValue::String(ym) = rec.data().get(&ym_tag).expect("YM present") else {
panic!("YM must be String")
};
let xm_bytes: &[u8] = xm.as_ref();
let ym_bytes: &[u8] = ym.as_ref();
for (x, y) in xm_bytes.iter().zip(ym_bytes.iter()) {
if *x == b'.' && *y == b'Z' {
saw_divergence = true;
break;
}
}
if saw_divergence {
break;
}
}
assert!(saw_divergence, "expected at least one record where XM='.' but YM='Z' due to errors");
}
#[test]
fn matrix_vcf_with_mtmb_and_mode_runs_chemistry() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--threads",
"1",
]);
assert!(ok, "expected simulate to succeed with methylated VCF + mode; stderr: {stderr}");
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
assert!(!r1.is_empty(), "expected non-empty FASTQ output");
let (c1, _t1) = count_c_and_t(&r1);
assert!(c1 > 0, "matrix (true,true): expected C's preserved under full methylation + em-seq");
}
#[test]
fn matrix_vcf_with_mtmb_no_mode_warns_and_runs_variants_only() {
let env = cytosine_rich_env();
let out_with_mode = env.dir.path().join("with_mode");
let out_no_mode = env.dir.path().join("no_mode");
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let (ok_mode, _, stderr_mode) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out_with_mode.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--seed",
"99",
"--threads",
"1",
]);
assert!(ok_mode, "chemistry run must succeed");
assert!(
!stderr_mode.contains("methylation chemistry will not be applied"),
"matrix (true,true): with-mode run must NOT warn about skipped chemistry; stderr: {stderr_mode}"
);
let (ok_no_mode, _, stderr_no_mode) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out_no_mode.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--seed",
"99",
"--threads",
"1",
]);
assert!(
ok_no_mode,
"matrix (true,false): expected simulate to succeed; stderr: {stderr_no_mode}"
);
assert!(
stderr_no_mode.contains("methylation chemistry will not be applied"),
"matrix (true,false): expected WARN about methylation being ignored; stderr: {stderr_no_mode}"
);
let r1_mode = read_gzipped(&out_with_mode.with_extension("r1.fastq.gz"));
let r1_no_mode = read_gzipped(&out_no_mode.with_extension("r1.fastq.gz"));
let (c_mode, _) = count_c_and_t(&r1_mode);
let (c_no_mode, _) = count_c_and_t(&r1_no_mode);
assert!(c_mode > 0, "mode run should have C's");
assert!(c_no_mode > 0, "no-mode run should also have C's (no chemistry applied)");
}
#[test]
fn matrix_vcf_no_mtmb_with_mode_errors() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let plain_vcf = env.write_vcf_header_only(&["sample1"], &[("chr1", 1000)]);
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
plain_vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--methylation-mode",
"em-seq",
"--threads",
"1",
]);
assert!(!ok, "matrix (false,true): expected simulate to fail with non-methylated VCF + mode");
assert!(
stderr.contains("run `holodeck methylate` first"),
"matrix (false,true): expected error message mentioning methylate; stderr: {stderr}"
);
}
#[test]
fn matrix_no_vcf_no_mode_is_variants_only() {
let env = cytosine_rich_env();
let out = env.output_prefix();
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--threads",
"1",
]);
assert!(
ok,
"matrix (false,false): expected variants-only simulate to succeed; stderr: {stderr}"
);
let r1 = read_gzipped(&out.with_extension("r1.fastq.gz"));
assert!(!r1.is_empty(), "expected non-empty FASTQ output");
}
#[test]
fn matrix_no_vcf_no_mode_is_byte_identical_across_runs() {
let env = cytosine_rich_env();
let out_a = env.output_prefix();
let out_b = env.dir.path().join("sim_b");
for out in [&out_a, &out_b] {
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--seed",
"1234",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
}
let r1_a = read_gzipped(&out_a.with_extension("r1.fastq.gz"));
let r1_b = read_gzipped(&out_b.with_extension("r1.fastq.gz"));
assert_eq!(r1_a, r1_b, "same --seed produced different R1 FASTQ output");
}
#[test]
fn cpg_truth_bedgraph_works_with_matrix_true_true_cell() {
let env = cytosine_rich_env();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 1.0, 42, "meth.vcf.gz");
let out = env.output_prefix();
let bg_path = env.dir.path().join("cpg.bedgraph");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--methylation-mode",
"em-seq",
"--cpg-truth-bedgraph",
bg_path.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--threads",
"1",
]);
assert!(ok, "simulate with --cpg-truth-bedgraph + --methylation-mode failed: {stderr}");
assert!(bg_path.exists(), "--cpg-truth-bedgraph file was not created");
let contents = std::fs::read_to_string(&bg_path).unwrap();
assert!(
contents.starts_with("track type="),
"--cpg-truth-bedgraph missing track header: {contents}"
);
let data_lines: Vec<&str> = contents.lines().filter(|l| !l.starts_with("track")).collect();
assert!(
!data_lines.is_empty(),
"--cpg-truth-bedgraph should have at least one per-CpG record under non-zero coverage"
);
}
#[test]
fn test_golden_bam_all_failed_sets_cf_one_and_retains_cytosines() {
use noodles::sam::alignment::record::data::field::Tag as DataTag;
use noodles::sam::alignment::record_buf::data::field::Value as DataValue;
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"1.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
assert!(!records.is_empty(), "expected non-empty golden BAM");
let cf = DataTag::new(b'c', b'f');
let ys = DataTag::new(b'Y', b'S');
let mut any_retained_c = false;
for rec in &records {
let cf_val =
rec.data().get(&cf).expect("cf:i must be present").as_int().expect("cf is int");
assert_eq!(cf_val, 1, "every molecule is a forced failure → cf:i:1");
let DataValue::String(ys_bytes) = rec.data().get(&ys).expect("YS:Z must be present") else {
panic!("YS must be a String tag");
};
let seq: Vec<u8> = rec.sequence().as_ref().to_vec();
let ys_slice: &[u8] = ys_bytes.as_ref();
assert_eq!(
seq, ys_slice,
"failed molecule must not convert: SEQ must equal pre-conversion YS"
);
any_retained_c |= seq.contains(&b'C');
}
assert!(any_retained_c, "cytosine-rich reads should retain C's when conversion fails");
}
#[test]
fn test_golden_bam_no_failure_sets_cf_zero() {
use noodles::sam::alignment::record::data::field::Tag as DataTag;
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"5",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"1.0",
"--methylation-failure-rate",
"0.0",
"--golden-bam",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
assert!(!records.is_empty(), "expected non-empty golden BAM");
let cf = DataTag::new(b'c', b'f');
for rec in &records {
let cf_val =
rec.data().get(&cf).expect("cf:i must be present").as_int().expect("cf is int");
assert_eq!(cf_val, 0, "no failures → cf:i:0 on every record");
}
}
#[test]
fn test_cf_identical_across_mates_and_mix_present() {
use std::collections::HashMap;
use noodles::sam::alignment::record::data::field::Tag as DataTag;
let env = cytosine_rich_env();
let out = env.output_prefix();
let vcf = methylate_to_vcf(&env, &env.fasta_path, 0.0, 42, "meth.vcf.gz");
let (ok, _, stderr) = run_simulate(&[
"simulate",
"-r",
env.fasta_path.to_str().unwrap(),
"-v",
vcf.to_str().unwrap(),
"-o",
out.to_str().unwrap(),
"--coverage",
"30",
"--read-length",
"50",
"--fragment-mean",
"100",
"--fragment-stddev",
"10",
"--min-error-rate",
"0",
"--max-error-rate",
"0",
"--methylation-mode",
"em-seq",
"--methylation-conversion-rate",
"0.999",
"--methylation-failure-rate",
"0.5",
"--golden-bam",
"--seed",
"7",
"--threads",
"1",
]);
assert!(ok, "simulate failed: {stderr}");
let records = read_bam_records(&out.with_extension("golden.bam"));
assert!(!records.is_empty(), "expected non-empty golden BAM");
let cf = DataTag::new(b'c', b'f');
let mut by_template: HashMap<String, Vec<i64>> = HashMap::new();
for rec in &records {
let name = std::str::from_utf8(rec.name().expect("read name present").as_ref())
.unwrap()
.to_string();
let cf_val =
rec.data().get(&cf).expect("cf:i must be present").as_int().expect("cf is int");
by_template.entry(name).or_default().push(cf_val);
}
let mut seen_failed = false;
let mut seen_converted = false;
for (name, cfs) in &by_template {
let first = cfs[0];
assert!(
cfs.iter().all(|&v| v == first),
"both mates of template {name} must share cf; got {cfs:?}"
);
match first {
0 => seen_converted = true,
1 => seen_failed = true,
other => panic!("cf must be 0 or 1, got {other}"),
}
}
assert!(seen_failed, "at failure_rate 0.5 some molecules must be flagged cf:i:1");
assert!(seen_converted, "at failure_rate 0.5 some molecules must be cf:i:0");
}