rdml-qpcr 0.1.1

Read, write, and validate RDML (Real-time PCR Data Markup Language) qPCR data files
Documentation
//! Container tests: zip round-trips, bare XML, unknown-member
//! preservation, and partition-table sidecars.

mod common;

use std::io::Cursor;

use common::kitchen_sink;
use pretty_assertions::assert_eq;
use rdml_qpcr::*;

#[test]
fn zip_round_trip_preserves_document_and_members() {
    let mut file = RdmlFile::new(kitchen_sink());
    // A vendor sidecar (the third-party extension convention) …
    file.insert_member("rapidx_thermals.bin", vec![0u8, 1, 2, 3, 255]);
    // … and the partition table the kitchen sink references.
    let mut table = PartitionTable::new();
    table
        .push_column(
            "HBV".parse().unwrap(),
            vec![
                PartitionPoint {
                    fluor: 3412.32,
                    score: PartitionScore::Positive,
                },
                PartitionPoint {
                    fluor: 239.23,
                    score: PartitionScore::Negative,
                },
            ],
        )
        .unwrap();
    table
        .push_column(
            "GAPDH".parse().unwrap(),
            vec![
                PartitionPoint {
                    fluor: 121.89,
                    score: PartitionScore::Negative,
                },
                PartitionPoint {
                    fluor: 3459.27,
                    score: PartitionScore::Undefined,
                },
            ],
        )
        .unwrap();
    file.insert_partition_table("plate1_well96.tsv", &table);

    let (bytes, report) = file.to_bytes(RdmlVersion::V1_4).unwrap();
    assert!(report.is_lossless());

    let back = RdmlFile::from_slice(&bytes).unwrap();
    assert_eq!(back.document, file.document);
    assert_eq!(back.source_version(), Some(RdmlVersion::V1_4));
    assert_eq!(back.read_notes(), &[]);
    // Vendor member preserved byte-exactly.
    assert_eq!(
        back.member("rapidx_thermals.bin"),
        Some(&[0u8, 1, 2, 3, 255][..])
    );
    // Partition table parses back identically, via the XML's reference.
    let end_pt_table = back
        .document
        .reactions()
        .find_map(|ctx| ctx.react.partitions.as_ref())
        .and_then(|p| p.end_pt_table.clone())
        .unwrap();
    assert_eq!(back.partition_table(&end_pt_table).unwrap(), table);

    // Second-generation bytes are stable.
    let (bytes2, _) = back.to_bytes(RdmlVersion::V1_4).unwrap();
    let third = RdmlFile::from_slice(&bytes2).unwrap();
    assert_eq!(third, back);
}

#[test]
fn bare_xml_is_accepted() {
    let (xml, _) = kitchen_sink().to_xml(RdmlVersion::V1_4).unwrap();
    let file = RdmlFile::from_slice(xml.as_bytes()).unwrap();
    assert_eq!(file.document, kitchen_sink());
    assert_eq!(file.source_version(), Some(RdmlVersion::V1_4));
}

#[test]
fn save_and_open_via_filesystem() {
    let dir = std::env::temp_dir().join(format!("rdml-qpcr-io-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let path = dir.join("out.rdml");
    let mut file = RdmlFile::new(kitchen_sink());
    // The fixture's dPCR react references this table; container
    // validation requires it to exist.
    file.insert_member(
        "partitions/plate1_well96.tsv",
        b"HBV\tHBV\n1.0\tp\n".to_vec(),
    );
    let report = file.save(&path).unwrap(); // 1.3 default
    // 1.3 drops the fixture's 1.4-only fields — reported, not silent.
    assert!(!report.is_lossless());
    let back = RdmlFile::open(&path).unwrap();
    assert_eq!(back.source_version(), Some(RdmlVersion::V1_3));
    assert!(back.document.sample("liver 1").is_some());
    std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn validation_gates_saving() {
    let mut doc = kitchen_sink();
    doc.samples.clear(); // dangling react->sample references
    let file = RdmlFile::new(doc);
    match file.to_bytes(RdmlVersion::V1_3) {
        Err(Error::Validation(report)) => assert!(report.errors().count() > 0),
        other => panic!("expected validation error, got {other:?}"),
    }
    // Unchecked write still works.
    let mut cursor = Cursor::new(Vec::new());
    file.write_unchecked(&mut cursor, RdmlVersion::V1_3)
        .unwrap();
    assert!(!cursor.into_inner().is_empty());
}

#[test]
fn container_validation_checks_partition_table_references() {
    // The kitchen sink references plate1_well96.tsv but we do not add it.
    let file = RdmlFile::new(kitchen_sink());
    let report = file.validate().unwrap_err();
    assert!(
        report
            .errors()
            .any(|f| f.path.contains("endPtTable") && f.message.contains("plate1_well96.tsv")),
        "{report}"
    );
    // Adding the member fixes it.
    let mut file = file;
    file.insert_member(
        "partitions/plate1_well96.tsv",
        b"HBV\tHBV\n1.0\tp\n".to_vec(),
    );
    assert!(file.validate().is_ok());
}

#[test]
fn nonstandard_document_member_name_is_tolerated_with_note() {
    use std::io::Write as _;
    use zip::write::SimpleFileOptions;
    let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
    let (xml, _) = kitchen_sink().to_xml(RdmlVersion::V1_3).unwrap();
    zip.start_file("run_export.xml", SimpleFileOptions::default())
        .unwrap();
    zip.write_all(xml.as_bytes()).unwrap();
    let bytes = zip.finish().unwrap().into_inner();
    let file = RdmlFile::from_slice(&bytes).unwrap();
    assert!(file.document.sample("liver 1").is_some());
    assert!(
        file.read_notes()
            .iter()
            .any(|n| n.message.contains("non-standard member name")),
        "{:?}",
        file.read_notes()
    );
}

#[test]
fn archive_without_document_is_rejected() {
    use std::io::Write as _;
    use zip::write::SimpleFileOptions;
    let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
    zip.start_file("readme.txt", SimpleFileOptions::default())
        .unwrap();
    zip.write_all(b"nothing here").unwrap();
    let bytes = zip.finish().unwrap().into_inner();
    let err = RdmlFile::from_slice(&bytes).unwrap_err();
    assert!(err.to_string().contains("rdml_data.xml"), "{err}");
}