use std::collections::HashMap;
use std::process::Command;
use slow5lib::aux::{AuxMeta, AuxValue};
use slow5lib::header::{Header, ReadGroup, RecordCompression, SignalCompression};
use slow5lib::record::Record;
use slow5lib::{Slow5IndexedReader, Slow5Reader, Slow5Writer};
const SMALL_BLOW5: &str = "tests/data/small.blow5";
const PARITY_INPUT: &str = "tests/data/parity_input.slow5";
fn find_slow5tools() -> Option<String> {
let ok = Command::new("slow5tools")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
Some("slow5tools".to_string())
} else {
None
}
}
fn skip_unless_slow5tools() -> Option<String> {
match find_slow5tools() {
Some(p) => Some(p),
None => {
eprintln!("c_parity: slow5tools not on PATH -- skipping");
None
}
}
}
fn skip_unless_slow5tools_zstd() -> Option<String> {
let st = skip_unless_slow5tools()?;
let tmp = "/tmp/slow5lib_probe_zstd.blow5";
let records = read_parity_input();
write_blow5(
tmp,
&records[..1],
RecordCompression::Zstd,
SignalCompression::SvbZd,
);
let out = Command::new(&st)
.args(["view", "--to", "slow5", tmp])
.output()
.expect("spawn slow5tools view");
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("not been compiled with zstd") {
eprintln!("c_parity: slow5tools lacks zstd support -- skipping zstd test");
return None;
}
}
Some(st)
}
fn skip_unless_small_blow5() -> bool {
if std::path::Path::new(SMALL_BLOW5).exists() {
true
} else {
eprintln!("c_parity: {SMALL_BLOW5} not present -- skipping");
false
}
}
fn read_parity_input() -> Vec<Record> {
let mut r = Slow5Reader::open(PARITY_INPUT).expect("open parity_input.slow5");
r.records().map(|rec| rec.expect("parity record")).collect()
}
fn write_blow5(path: &str, records: &[Record], rec: RecordCompression, sig: SignalCompression) {
let header = Header {
version: (0, 2, 0),
num_read_groups: 1,
record_compression: rec,
signal_compression: sig,
read_groups: vec![ReadGroup::default()],
aux_meta: AuxMeta::default(),
};
let mut writer =
Slow5Writer::create(path, header).unwrap_or_else(|e| panic!("create {path}: {e}"));
for r in records {
writer
.write(r)
.unwrap_or_else(|e| panic!("write {path}: {e}"));
}
writer
.finish()
.unwrap_or_else(|e| panic!("finish {path}: {e}"));
}
fn aux_approx_eq(a: &AuxValue, b: &AuxValue) -> bool {
match (a, b) {
(AuxValue::Float(x), AuxValue::Float(y)) => {
if x == y {
return true;
}
let max = x.abs().max(y.abs());
if max == 0.0 {
return true;
}
((x - y) / max).abs() < 1e-4
}
(AuxValue::Double(x), AuxValue::Double(y)) => {
if x == y {
return true;
}
let max = x.abs().max(y.abs());
if max == 0.0 {
return true;
}
((x - y) / max).abs() < 1e-6
}
_ => a == b,
}
}
#[test]
fn c_parity_slow5tools_output_matches_rust_reader() {
let Some(slow5tools) = skip_unless_slow5tools() else {
return;
};
if !skip_unless_small_blow5() {
return;
}
let reader = Slow5IndexedReader::open(SMALL_BLOW5).expect("open small.blow5");
let ids: Vec<String> = reader.read_ids().take(5).map(str::to_string).collect();
assert!(!ids.is_empty(), "small.blow5 has no reads");
let tmp = "/tmp/slow5lib_c_parity_get.slow5";
let status = Command::new(&slow5tools)
.args(["get", "--to", "slow5", "-o", tmp])
.arg(SMALL_BLOW5)
.args(&ids)
.status()
.expect("spawn slow5tools get");
assert!(
status.success(),
"slow5tools get exited with status {status}"
);
let mut text_reader = Slow5Reader::open(tmp).expect("open slow5tools output");
let extracted: Vec<_> = text_reader
.records()
.map(|r| r.expect("record from slow5tools output"))
.collect();
assert_eq!(extracted.len(), ids.len());
for rec in &extracted {
let from_rust = reader
.get(&rec.read_id)
.unwrap_or_else(|e| panic!("rust get {}: {e}", rec.read_id));
assert_eq!(
from_rust.raw_signal, rec.raw_signal,
"signal mismatch for {}",
rec.read_id
);
}
}
#[test]
fn c_parity_zstd_rust_writer_readable_by_slow5tools() {
let Some(slow5tools) = skip_unless_slow5tools_zstd() else {
return;
};
let records = read_parity_input();
let tmp = "/tmp/slow5lib_c_parity_zstd.blow5";
write_blow5(
tmp,
&records,
RecordCompression::Zstd,
SignalCompression::SvbZd,
);
let output = Command::new(&slow5tools)
.args(["view", "--to", "slow5", tmp])
.output()
.expect("spawn slow5tools view");
assert!(
output.status.success(),
"slow5tools view (zstd) failed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let data_lines = stdout
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with('@') && !l.is_empty())
.count();
assert_eq!(
data_lines,
records.len(),
"zstd: wrong data line count from slow5tools"
);
}
#[test]
fn c_parity_zlib_c_generated_readable() {
let Some(slow5tools) = skip_unless_slow5tools() else {
return;
};
let tmp = "/tmp/slow5lib_c_parity_zlib_gen.blow5";
let status = Command::new(&slow5tools)
.args([
"view", "--to", "blow5", "-c", "zlib", "-s", "svb-zd", "-o", tmp,
])
.arg(PARITY_INPUT)
.status()
.expect("spawn slow5tools view (convert to zlib)");
assert!(status.success(), "slow5tools convert to zlib failed");
let source = read_parity_input();
let mut rust_reader = Slow5Reader::open(tmp).expect("open zlib blow5");
let from_zlib: Vec<_> = rust_reader
.records()
.map(|r| r.expect("zlib record"))
.collect();
assert_eq!(from_zlib.len(), source.len(), "zlib: record count mismatch");
for (i, (got, want)) in from_zlib.iter().zip(source.iter()).enumerate() {
assert_eq!(got.read_id, want.read_id, "record {i}: read_id mismatch");
assert_eq!(
got.raw_signal, want.raw_signal,
"record {i}: signal mismatch"
);
}
}
#[test]
fn c_parity_zlib_rust_writer_readable_by_slow5tools() {
let Some(slow5tools) = skip_unless_slow5tools() else {
return;
};
let records = read_parity_input();
let tmp = "/tmp/slow5lib_c_parity_zlib_rust.blow5";
write_blow5(
tmp,
&records,
RecordCompression::Zlib,
SignalCompression::SvbZd,
);
let output = Command::new(&slow5tools)
.args(["view", "--to", "slow5", tmp])
.output()
.expect("spawn slow5tools view (zlib)");
assert!(
output.status.success(),
"slow5tools view (zlib) failed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let data_lines = stdout
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with('@') && !l.is_empty())
.count();
assert_eq!(
data_lines,
records.len(),
"zlib: wrong data line count from slow5tools"
);
}
#[test]
fn c_parity_aux_fields_blow5_vs_slow5_text() {
let Some(slow5tools) = skip_unless_slow5tools() else {
return;
};
if !skip_unless_small_blow5() {
return;
}
let blow5 = Slow5IndexedReader::open(SMALL_BLOW5).expect("open small.blow5");
let ids: Vec<String> = blow5.read_ids().take(3).map(str::to_string).collect();
assert!(!ids.is_empty(), "small.blow5 has no reads");
let tmp = "/tmp/slow5lib_c_parity_aux.slow5";
let status = Command::new(&slow5tools)
.args(["get", "--to", "slow5", "-o", tmp])
.arg(SMALL_BLOW5)
.args(&ids)
.status()
.expect("spawn slow5tools get");
assert!(status.success(), "slow5tools get (aux) exited {status}");
let mut text_reader = Slow5Reader::open(tmp).expect("open slow5tools output");
let text_by_id: HashMap<String, Record> = text_reader
.records()
.map(|r| {
let rec = r.expect("aux text record");
(rec.read_id.clone(), rec)
})
.collect();
let aux_names = blow5.header().aux_meta.names.clone();
assert!(!aux_names.is_empty(), "small.blow5 has no aux fields");
for id in &ids {
let from_blow5 = blow5
.get(id)
.unwrap_or_else(|e| panic!("indexed get {id}: {e}"));
let from_text = text_by_id
.get(id.as_str())
.unwrap_or_else(|| panic!("id {id} not in slow5tools output"));
for name in &aux_names {
let v_blow5 = from_blow5
.aux
.get(name.as_str())
.cloned()
.unwrap_or(AuxValue::Missing);
let v_text = from_text
.aux
.get(name.as_str())
.cloned()
.unwrap_or(AuxValue::Missing);
assert!(
aux_approx_eq(&v_blow5, &v_text),
"read {id} aux field {name}: blow5={v_blow5:?} text={v_text:?}"
);
}
}
}
#[test]
fn c_parity_exzd_c_generated_readable() {
let Some(slow5tools) = skip_unless_slow5tools() else {
return;
};
let tmp = "/tmp/slow5lib_c_parity_exzd_gen.blow5";
let status = Command::new(&slow5tools)
.args([
"view", "--to", "blow5", "-c", "none", "-s", "ex-zd", "-o", tmp,
])
.arg(PARITY_INPUT)
.status()
.expect("spawn slow5tools view (convert to ex-zd)");
assert!(status.success(), "slow5tools convert to ex-zd failed");
let source = read_parity_input();
let mut rust_reader = Slow5Reader::open(tmp).expect("open ex-zd blow5");
let from_exzd: Vec<_> = rust_reader
.records()
.map(|r| r.expect("ex-zd record"))
.collect();
assert_eq!(
from_exzd.len(),
source.len(),
"ex-zd: record count mismatch"
);
for (i, (got, want)) in from_exzd.iter().zip(source.iter()).enumerate() {
assert_eq!(got.read_id, want.read_id, "record {i}: read_id mismatch");
assert_eq!(
got.raw_signal, want.raw_signal,
"record {i}: signal mismatch"
);
}
}
#[test]
fn c_parity_exzd_rust_writer_readable_by_slow5tools() {
let Some(slow5tools) = skip_unless_slow5tools_zstd() else {
return;
};
let records = read_parity_input();
let tmp = "/tmp/slow5lib_c_parity_exzd_rust.blow5";
write_blow5(
tmp,
&records,
RecordCompression::Zstd,
SignalCompression::ExZd,
);
let output = Command::new(&slow5tools)
.args(["view", "--to", "slow5", tmp])
.output()
.expect("spawn slow5tools view");
assert!(
output.status.success(),
"slow5tools view (ex-zd) failed:\nstderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let mut lines = stdout
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with('@') && !l.is_empty());
let data_lines: Vec<&str> = lines.by_ref().collect();
assert_eq!(
data_lines.len(),
records.len(),
"ex-zd: wrong data line count from slow5tools"
);
for (line, want) in data_lines.iter().zip(records.iter()) {
let cols: Vec<&str> = line.split('\t').collect();
let read_id = cols[0];
let raw_signal_str = cols[cols.len() - 1];
let got_signal: Vec<i16> = if raw_signal_str.is_empty() {
Vec::new()
} else {
raw_signal_str
.split(',')
.map(|s| s.parse().expect("parse signal sample"))
.collect()
};
assert_eq!(read_id, want.read_id, "ex-zd: read_id mismatch");
assert_eq!(
got_signal, want.raw_signal,
"ex-zd: signal mismatch for {read_id}"
);
}
}
#[test]
fn c_parity_small_blow5_accepted_by_slow5tools() {
let Some(slow5tools) = skip_unless_slow5tools() else {
return;
};
if !skip_unless_small_blow5() {
return;
}
let output = Command::new(&slow5tools)
.args(["view", "--to", "slow5", SMALL_BLOW5])
.output()
.expect("spawn slow5tools view");
assert!(
output.status.success(),
"slow5tools view exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}