Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions tests/integration/helpers/assertions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,31 @@ pub fn assert_different_molecule_ids(family1: &[RecordBuf], family2: &[RecordBuf
assert_ne!(mi1, mi2, "Different UMI families should have different molecule IDs");
}

/// Asserts that `bam` is actually sorted in `order`, using fgumi's own canonical
/// sort-order verifier (`fgumi sort --verify`).
///
/// A command's `@HD` `SO`/`GO`/`SS` tags are only a *promise* that the output is in
/// that order; this checks the promise against the bytes the command wrote. Pass
/// `key_types = Some("mi")` for template-coordinate BAMs that carry MI tags (e.g.
/// `group`/`dedup` output), mirroring `fgumi sort --key-types`.
///
/// # Panics
///
/// Panics if the `fgumi` binary cannot be spawned or reports any sort-order violation.
pub fn assert_bam_sorted(bam: &std::path::Path, order: &str, key_types: Option<&str>) {
let bam_path = bam.to_str().expect("BAM path is valid UTF-8");
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_fgumi"));
cmd.args(["sort", "-i", bam_path, "--verify", "--order", order]);
if let Some(kt) = key_types {
cmd.args(["--key-types", kt]);
}
let status = cmd.status().expect("failed to spawn `fgumi sort --verify`");
assert!(
status.success(),
"{bam_path} is not sorted by {order}: `fgumi sort --verify` reported violations"
);
Comment thread
nh13 marked this conversation as resolved.
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
34 changes: 30 additions & 4 deletions tests/integration/test_dedup_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,49 @@ use fgumi_raw_bam::{RawRecord, SamBuilder, flags};
use noodles::bam;
use noodles::sam::alignment::io::Write as AlignmentWrite;
use std::fs;
use std::path::PathBuf;
use std::path::Path;
use tempfile::TempDir;

use crate::helpers::assertions::assert_bam_sorted;
use crate::helpers::bam_generator::{create_minimal_header, to_record_buf};

/// Create a template-coordinate sorted BAM with UMI-tagged reads.
///
/// Template-coordinate sort groups reads by position, then by name within each position.
/// The header must have SO:unsorted GO:query SS:template-coordinate tags.
fn create_sorted_bam(path: &PathBuf, records: Vec<RawRecord>) {
fn create_sorted_bam(path: &Path, records: Vec<RawRecord>) {
// Write the records to a temp BAM, then sort it into `path` with fgumi's own
// template-coordinate sorter so the dedup input is *genuinely* in
// template-coordinate order rather than merely labelled as such. (dedup trusts
// the header's SS tag; feeding it mislabelled-but-unsorted input produces
// mislabelled-but-unsorted output.)
let unsorted = path.with_file_name("dedup_input_unsorted.bam");
let header = create_minimal_header("chr1", 10000);
let mut writer =
bam::io::Writer::new(fs::File::create(path).expect("Failed to create BAM file"));
bam::io::Writer::new(fs::File::create(&unsorted).expect("Failed to create BAM file"));
writer.write_header(&header).expect("Failed to write header");

for record in records {
writer
.write_alignment_record(&header, &to_record_buf(&record))
.expect("Failed to write record");
}
writer.try_finish().expect("Failed to finish BAM");

let status = std::process::Command::new(env!("CARGO_BIN_EXE_fgumi"))
.args([
"sort",
"-i",
unsorted.to_str().unwrap(),
"-o",
path.to_str().unwrap(),
"--order",
"template-coordinate",
"--key-types",
"mi",
])
.status()
.expect("failed to spawn `fgumi sort` for dedup test input");
assert!(status.success(), "failed to template-coordinate sort dedup test input");
}

/// Create a group of paired-end reads at the same position with the same UMI
Expand Down Expand Up @@ -112,6 +134,10 @@ fn test_dedup_command_basic() {
cmd.execute("fgumi dedup").expect("Dedup command failed");
assert!(output_bam.exists(), "Output BAM not created");

// dedup consumes and re-emits template-coordinate order; gate that its output
// still verifies as template-coordinate sorted (SS:template-coordinate).
assert_bam_sorted(&output_bam, "template-coordinate", Some("mi"));

// All reads should be present (duplicates are marked, not removed)
let mut reader = bam::io::Reader::new(fs::File::open(&output_bam).unwrap());
let _header = reader.read_header().unwrap();
Expand Down
5 changes: 5 additions & 0 deletions tests/integration/test_group_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;

use crate::helpers::assertions::assert_bam_sorted;
use crate::helpers::bam_generator::{create_minimal_header, create_umi_family, to_record_buf};

/// Test that the group command properly writes metrics in the new format.
Expand Down Expand Up @@ -47,6 +48,10 @@ fn test_group_command_writes_new_metrics() {
assert!(output_bam.exists(), "Output BAM not created");
assert!(metrics_file.exists(), "Metrics file not created");

// group's `@HD` advertises SS:template-coordinate ("Output is always written in
// template-coordinate order"); gate that claim against the bytes it wrote.
assert_bam_sorted(&output_bam, "template-coordinate", Some("mi"));

// Read and validate metrics
let metrics: Vec<UmiGroupingMetrics> =
DelimFile::default().read_tsv(&metrics_file).expect("Failed to read metrics file");
Expand Down
126 changes: 123 additions & 3 deletions tests/integration/test_merge_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,22 @@
use fgumi_lib::commands::command::Command;
use fgumi_lib::commands::merge::Merge;
use fgumi_lib::commands::sort::SortOrderArg;
use fgumi_raw_bam::{RawRecord, SamBuilder};
use fgumi_lib::sam::SamTag;
use fgumi_raw_bam::{RawRecord, SamBuilder, flags};
use noodles::bam;
use noodles::sam::Header;
use noodles::sam::alignment::io::Write as AlignmentWrite;
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use tempfile::TempDir;

use crate::helpers::bam_generator::{create_coordinate_sorted_header, to_record_buf};
use crate::helpers::assertions::assert_bam_sorted;
use crate::helpers::bam_generator::{
create_coordinate_sorted_header, create_minimal_header, to_record_buf,
};

/// Path to the built `fgumi` binary, for the end-to-end merge-output test.
const FGUMI: &str = env!("CARGO_BIN_EXE_fgumi");

/// A mapped 20M record on chr1 (ref 0) at the given 1-based position.
fn mapped_record(name: &[u8], pos: i32) -> RawRecord {
Expand Down Expand Up @@ -496,3 +503,116 @@ fn test_merge_streaming_verify_accepts_sorted_for_each_order(
"merged records must retain their identity and input order"
);
}

// -----------------------------------------------------------------------------
// End-to-end output-order gate (drives the built `fgumi` binary): `fgumi merge`
// advertises `SS:template-coordinate` on its output, so build two genuinely
// template-coordinate sorted inputs, merge them, and assert the merged bytes
// verify as template-coordinate sorted via `fgumi sort --verify`.
// -----------------------------------------------------------------------------

/// A mapped F1R2 pair at `start` (0-based) on ref 0 with the given name/UMI.
fn mapped_pair(name: &str, umi: &str, start: i32) -> Vec<RawRecord> {
let mut r1 = SamBuilder::new();
r1.read_name(name.as_bytes())
.sequence(b"ACGTACGT")
.qualities(&[30; 8])
.flags(flags::PAIRED | flags::FIRST_SEGMENT)
.ref_id(0)
.pos(start)
.mapq(60)
.cigar_ops(&[8 << 4])
.mate_ref_id(0)
.mate_pos(start + 100)
.template_length(108)
.add_string_tag(SamTag::RX, umi.as_bytes())
.add_string_tag(SamTag::MC, b"8M");
let mut r2 = SamBuilder::new();
r2.read_name(name.as_bytes())
.sequence(b"ACGTACGT")
.qualities(&[30; 8])
.flags(flags::PAIRED | flags::LAST_SEGMENT | flags::REVERSE)
.ref_id(0)
.pos(start + 100)
.mapq(60)
.cigar_ops(&[8 << 4])
.mate_ref_id(0)
.mate_pos(start)
.template_length(-108)
.add_string_tag(SamTag::RX, umi.as_bytes())
.add_string_tag(SamTag::MC, b"8M");
vec![r1.build(), r2.build()]
}

/// Write `records` to a temp BAM, then sort it into `path` in genuine
/// template-coordinate order with fgumi's own sorter.
fn write_template_coordinate_bam(dir: &Path, name: &str, records: Vec<RawRecord>) -> PathBuf {
let unsorted = dir.join(format!("{name}.unsorted.bam"));
let sorted = dir.join(format!("{name}.bam"));
let header = create_minimal_header("chr1", 100_000);
let mut writer = bam::io::Writer::new(fs::File::create(&unsorted).unwrap());
writer.write_header(&header).unwrap();
for record in records {
writer.write_alignment_record(&header, &to_record_buf(&record)).unwrap();
}
writer.try_finish().unwrap();

let status = std::process::Command::new(FGUMI)
.args([
"sort",
"-i",
unsorted.to_str().unwrap(),
"-o",
sorted.to_str().unwrap(),
"--order",
"template-coordinate",
"--key-types",
"mi",
])
.status()
.expect("spawn fgumi sort");
assert!(status.success(), "failed to sort merge input {name}");
sorted
}

#[test]
fn test_merge_output_is_template_coordinate_sorted() {
let dir = TempDir::new().unwrap();
// Two inputs whose positions interleave, so a correct merge must reorder across
// files (not just concatenate).
let in1 = write_template_coordinate_bam(
dir.path(),
"in1",
[mapped_pair("a", "ACGTACGT", 200), mapped_pair("c", "ACGTACGT", 5_000)]
.into_iter()
.flatten()
.collect(),
);
let in2 = write_template_coordinate_bam(
dir.path(),
"in2",
[mapped_pair("b", "TGCATGCA", 1_000), mapped_pair("d", "TGCATGCA", 8_000)]
.into_iter()
.flatten()
.collect(),
);

let merged = dir.path().join("merged.bam");
let status = std::process::Command::new(FGUMI)
.args([
"merge",
"-o",
merged.to_str().unwrap(),
"--order",
"template-coordinate",
in1.to_str().unwrap(),
in2.to_str().unwrap(),
])
.status()
.expect("spawn fgumi merge");
assert!(status.success(), "fgumi merge failed");
assert!(merged.exists(), "merged BAM not created");

// The whole point of merge is the output claim: verify it holds.
assert_bam_sorted(&merged, "template-coordinate", Some("mi"));
}
Loading