#![cfg(feature = "write")]
use biodream::{
ByteOrder, Channel, ChannelData, Datafile, FileRevision, GraphMetadata, Journal, Marker,
MarkerStyle, ReadOptions, WriteOptions, write_stream,
};
const fn base_metadata(channels: u16, sps: f64) -> GraphMetadata {
GraphMetadata {
file_revision: FileRevision::new(43),
samples_per_second: sps,
channel_count: channels,
byte_order: ByteOrder::LittleEndian,
compressed: false,
title: None,
acquisition_datetime: None,
max_samples_per_second: None,
}
}
fn two_channel_datafile() -> Datafile {
Datafile {
metadata: base_metadata(2, 1000.0),
channels: vec![
Channel {
name: String::from("ECG"),
units: String::from("mV"),
samples_per_second: 1000.0,
frequency_divider: 1,
data: ChannelData::Raw(vec![10, 20, 30, 40]),
point_count: 4,
},
Channel {
name: String::from("RESP"),
units: String::from("Ohm"),
samples_per_second: 500.0,
frequency_divider: 2,
data: ChannelData::Raw(vec![100, 200]),
point_count: 2,
},
],
markers: Vec::new(),
journal: None,
}
}
fn write_to_bytes(df: &Datafile) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf: Vec<u8> = Vec::new();
write_stream(df, &mut buf)?;
Ok(buf)
}
fn write_to_bytes_with(
df: &Datafile,
opts: &WriteOptions,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf: Vec<u8> = Vec::new();
opts.write_stream(df, &mut buf)?;
Ok(buf)
}
#[test]
fn write_roundtrip_basic() -> Result<(), Box<dyn std::error::Error>> {
let original = two_channel_datafile();
let bytes = write_to_bytes_with(&original, &WriteOptions::new().revision(44))?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
assert!(
(parsed.metadata.samples_per_second - 1000.0).abs() < f64::EPSILON,
"samples_per_second should round-trip as 1000.0"
);
assert_eq!(parsed.channels.len(), 2);
let ch0 = parsed.channels.first().ok_or("channel 0 not present")?;
assert_eq!(ch0.name, "ECG");
assert_eq!(ch0.units, "mV");
assert_eq!(ch0.frequency_divider, 1);
assert_eq!(ch0.point_count, 4);
let ChannelData::Raw(ref v) = ch0.data else {
return Err("expected Raw channel data for ECG".into());
};
assert_eq!(v.as_slice(), &[10i16, 20, 30, 40]);
let ch1 = parsed.channels.get(1).ok_or("channel 1 not present")?;
assert_eq!(ch1.name, "RESP");
assert_eq!(ch1.frequency_divider, 2);
assert_eq!(ch1.point_count, 2);
let ChannelData::Raw(ref v) = ch1.data else {
return Err("expected Raw channel data for RESP".into());
};
assert_eq!(v.as_slice(), &[100i16, 200]);
assert!(parsed.markers.is_empty());
assert!(parsed.journal.is_none());
Ok(())
}
#[test]
fn write_roundtrip_float_channel() -> Result<(), Box<dyn std::error::Error>> {
let df = Datafile {
metadata: base_metadata(1, 500.0),
channels: vec![Channel {
name: String::from("Temp"),
units: String::from("°C"),
samples_per_second: 500.0,
frequency_divider: 1,
data: ChannelData::Float(vec![36.5, 36.7, 36.9, 37.1]),
point_count: 4,
}],
markers: Vec::new(),
journal: None,
};
let bytes = write_to_bytes(&df)?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
let ch = parsed.channels.first().ok_or("channel 0 not present")?;
assert_eq!(ch.name, "Temp");
assert_eq!(ch.units, "°C");
let ChannelData::Float(ref v) = ch.data else {
return Err("expected Float channel data for Temp".into());
};
assert_eq!(v.len(), 4);
let delta: f64 = v
.iter()
.zip([36.5f64, 36.7, 36.9, 37.1].iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0f64, f64::max);
assert!(
delta < 1e-10,
"float samples should round-trip exactly; max delta = {delta}"
);
Ok(())
}
#[test]
fn write_roundtrip_scaled_channel() -> Result<(), Box<dyn std::error::Error>> {
let df = Datafile {
metadata: base_metadata(1, 1000.0),
channels: vec![Channel {
name: String::from("EEG"),
units: String::from("µV"),
samples_per_second: 1000.0,
frequency_divider: 1,
data: ChannelData::Scaled {
raw: vec![100i16, 200, -100],
scale: 0.5,
offset: 10.0,
},
point_count: 3,
}],
markers: Vec::new(),
journal: None,
};
let bytes = write_to_bytes(&df)?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
let ch = parsed.channels.first().ok_or("channel 0 not present")?;
assert_eq!(ch.name, "EEG");
assert_eq!(ch.point_count, 3);
let ChannelData::Scaled {
ref raw,
scale,
offset,
} = ch.data
else {
return Err("expected Scaled channel data".into());
};
assert_eq!(raw.as_slice(), &[100i16, 200, -100]);
assert!((scale - 0.5).abs() < 1e-12, "scale should round-trip");
assert!((offset - 10.0).abs() < 1e-12, "offset should round-trip");
Ok(())
}
#[test]
fn write_roundtrip_markers() -> Result<(), Box<dyn std::error::Error>> {
let markers = vec![
Marker {
label: String::from("Start"),
global_sample_index: 0,
channel: None,
style: MarkerStyle::Append,
created_at: None,
},
Marker {
label: String::from("Event1"),
global_sample_index: 100,
channel: Some(0),
style: MarkerStyle::UserEvent,
created_at: None,
},
Marker {
label: String::from("End"),
global_sample_index: 999,
channel: None,
style: MarkerStyle::Append,
created_at: None,
},
];
let df = Datafile {
metadata: base_metadata(1, 1000.0),
channels: vec![Channel {
name: String::from("ECG"),
units: String::from("mV"),
samples_per_second: 1000.0,
frequency_divider: 1,
data: ChannelData::Raw(vec![0i16; 1000]),
point_count: 1000,
}],
markers,
journal: None,
};
let bytes = write_to_bytes(&df)?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
assert_eq!(parsed.markers.len(), 3);
let m0 = parsed.markers.first().ok_or("marker 0 not present")?;
assert_eq!(m0.label, "Start");
assert_eq!(m0.global_sample_index, 0);
assert!(m0.channel.is_none());
let m1 = parsed.markers.get(1).ok_or("marker 1 not present")?;
assert_eq!(m1.label, "Event1");
assert_eq!(m1.global_sample_index, 100);
assert_eq!(m1.channel, Some(0));
let m2 = parsed.markers.get(2).ok_or("marker 2 not present")?;
assert_eq!(m2.label, "End");
assert_eq!(m2.global_sample_index, 999);
Ok(())
}
#[test]
fn write_roundtrip_journal() -> Result<(), Box<dyn std::error::Error>> {
let df = Datafile {
metadata: base_metadata(1, 1000.0),
channels: vec![Channel {
name: String::from("ECG"),
units: String::from("mV"),
samples_per_second: 1000.0,
frequency_divider: 1,
data: ChannelData::Raw(vec![1i16, 2, 3]),
point_count: 3,
}],
markers: Vec::new(),
journal: Some(Journal::Plain(String::from(
"Subject: Test\nCondition: Resting\n",
))),
};
let bytes = write_to_bytes(&df)?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
let journal = parsed.journal.ok_or("journal should be present")?;
assert_eq!(journal.as_text(), "Subject: Test\nCondition: Resting\n");
Ok(())
}
#[test]
fn write_file_then_read_file() -> Result<(), Box<dyn std::error::Error>> {
let df = two_channel_datafile();
let path = std::env::temp_dir().join("biodream_write_test_roundtrip.acq");
biodream::write_file(&df, &path)?;
let parsed = biodream::read_file(&path)?.into_value();
assert_eq!(parsed.channels.len(), 2);
assert_eq!(
parsed.channels.first().map(|c| c.name.as_str()),
Some("ECG")
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[test]
fn write_opts_revision_38() -> Result<(), Box<dyn std::error::Error>> {
let df = two_channel_datafile();
let opts = WriteOptions::new().revision(38);
let bytes = write_to_bytes_with(&df, &opts)?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
assert_eq!(parsed.metadata.file_revision.0, 38);
assert_eq!(parsed.channels.len(), 2);
Ok(())
}
#[test]
fn write_opts_revision_43() -> Result<(), Box<dyn std::error::Error>> {
let df = two_channel_datafile();
let opts = WriteOptions::new().revision(43);
let bytes = write_to_bytes_with(&df, &opts)?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
assert_eq!(parsed.metadata.file_revision.0, 43);
assert_eq!(parsed.channels.len(), 2);
Ok(())
}
#[test]
fn write_opts_revision_68_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let df = two_channel_datafile();
let opts = WriteOptions::new().revision(68).compressed(false);
let compressed_opts = WriteOptions::new().compressed(true);
let bytes = write_to_bytes_with(&df, &compressed_opts)?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
assert_eq!(parsed.metadata.file_revision.0, 68);
assert!(parsed.metadata.compressed, "should be marked compressed");
assert_eq!(parsed.channels.len(), 2);
let ch0 = parsed.channels.first().ok_or("channel 0 not present")?;
assert_eq!(ch0.point_count, 4);
let _ = opts; Ok(())
}
#[test]
fn write_compressed_smaller_than_uncompressed() -> Result<(), Box<dyn std::error::Error>> {
let samples: Vec<i16> = (0..2000).map(|i| (i % 100) as i16).collect();
let n = samples.len();
let df = Datafile {
metadata: base_metadata(1, 1000.0),
channels: vec![Channel {
name: String::from("Signal"),
units: String::from("mV"),
samples_per_second: 1000.0,
frequency_divider: 1,
data: ChannelData::Raw(samples),
point_count: n,
}],
markers: Vec::new(),
journal: None,
};
let uncompressed = write_to_bytes(&df)?;
let compressed = write_to_bytes_with(&df, &WriteOptions::new().compressed(true))?;
assert!(
compressed.len() < uncompressed.len(),
"compressed ({} bytes) should be smaller than uncompressed ({} bytes)",
compressed.len(),
uncompressed.len()
);
Ok(())
}
#[test]
fn write_compressed_data_matches_uncompressed() -> Result<(), Box<dyn std::error::Error>> {
let df = two_channel_datafile();
let uncompressed_bytes = write_to_bytes_with(&df, &WriteOptions::new().revision(44))?;
let compressed_bytes = write_to_bytes_with(&df, &WriteOptions::new().compressed(true))?;
let plain = ReadOptions::new()
.read_bytes(&uncompressed_bytes)?
.into_value();
let comp = ReadOptions::new()
.read_bytes(&compressed_bytes)?
.into_value();
assert_eq!(plain.channels.len(), comp.channels.len());
for (pc, cc) in plain.channels.iter().zip(comp.channels.iter()) {
assert_eq!(pc.name, cc.name, "channel names must match");
assert_eq!(pc.point_count, cc.point_count, "sample counts must match");
let pv = pc.scaled_samples();
let cv = cc.scaled_samples();
let delta = pv
.iter()
.zip(cv.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0f64, f64::max);
assert!(
delta < 1e-9,
"compressed/uncompressed sample mismatch for channel '{}': max delta={delta}",
pc.name
);
}
Ok(())
}
#[test]
fn write_interleave_pattern_unit() {
use biodream::parser::interleaved::compute_sample_pattern;
let p = compute_sample_pattern(&[1, 2]);
assert_eq!(p, vec![0, 1, 0], "interleave pattern for [1,2]");
let p2 = compute_sample_pattern(&[1, 1]);
assert_eq!(p2, vec![0, 1], "interleave pattern for [1,1]");
let p3 = compute_sample_pattern(&[1, 2, 4]);
assert_eq!(
p3,
vec![0, 1, 2, 0, 0, 1, 0],
"interleave pattern for [1,2,4]"
);
}
#[test]
fn write_interleave_byte_layout() -> Result<(), Box<dyn std::error::Error>> {
let df = Datafile {
metadata: base_metadata(2, 1000.0),
channels: vec![
Channel {
name: String::from("A"),
units: String::from("V"),
samples_per_second: 1000.0,
frequency_divider: 1,
data: ChannelData::Raw(vec![1i16, 2]),
point_count: 2,
},
Channel {
name: String::from("B"),
units: String::from("V"),
samples_per_second: 500.0,
frequency_divider: 2,
data: ChannelData::Raw(vec![10i16]),
point_count: 1,
},
],
markers: Vec::new(),
journal: None,
};
let bytes = write_to_bytes(&df)?;
let data_start = 772usize;
let data_bytes = bytes
.get(data_start..)
.ok_or("bytes must be long enough for data section")?;
let expected: [u8; 6] = [
1u8, 0, 10, 0, 2, 0, ];
assert!(
data_bytes.len() >= 6,
"data section must have at least 6 bytes; got {}",
data_bytes.len()
);
assert_eq!(
data_bytes.get(..6),
Some(expected.as_slice()),
"interleaved byte layout mismatch"
);
Ok(())
}
#[test]
fn write_marker_section_unit() -> Result<(), Box<dyn std::error::Error>> {
let df = Datafile {
metadata: base_metadata(1, 1000.0),
channels: vec![Channel {
name: String::from("ECG"),
units: String::from("mV"),
samples_per_second: 1000.0,
frequency_divider: 1,
data: ChannelData::Raw(vec![0i16]),
point_count: 1,
}],
markers: vec![Marker {
label: String::from("peak"),
global_sample_index: 42,
channel: None,
style: MarkerStyle::Append,
created_at: None,
}],
journal: None,
};
let bytes = write_to_bytes(&df)?;
let data_end = 256 + 252 + 4 + 4 + 2; let marker_start = data_end;
let marker_bytes = bytes
.get(marker_start..)
.ok_or("bytes must extend past data section")?;
let l_length = i32::from_le_bytes(
marker_bytes
.get(..4)
.ok_or("not enough bytes for lLength")?
.try_into()?,
);
let n_markers = i32::from_le_bytes(
marker_bytes
.get(4..8)
.ok_or("not enough bytes for lNumMarkers")?
.try_into()?,
);
assert_eq!(l_length, 26, "lLength mismatch");
assert_eq!(n_markers, 1, "lNumMarkers mismatch");
let marker_rec = marker_bytes.get(8..).ok_or("marker record bytes missing")?;
let l_sample = i32::from_le_bytes(
marker_rec
.get(..4)
.ok_or("not enough bytes for lSample")?
.try_into()?,
);
assert_eq!(l_sample, 42, "lSample mismatch");
let n_channel = i16::from_le_bytes(
marker_rec
.get(4..6)
.ok_or("not enough bytes for nChannel")?
.try_into()?,
);
assert_eq!(n_channel, -1i16, "nChannel should be -1 for global marker");
let style = marker_rec.get(6..10).ok_or("not enough bytes for style")?;
assert_eq!(style, b"apnd", "style code must be 'apnd'");
let text_len = i32::from_le_bytes(
marker_rec
.get(10..14)
.ok_or("not enough bytes for lMarkerTextLen")?
.try_into()?,
);
assert_eq!(text_len, 4, "lMarkerTextLen must be 4 for 'peak'");
let text = marker_rec.get(14..18).ok_or("not enough bytes for text")?;
assert_eq!(text, b"peak", "marker text bytes mismatch");
Ok(())
}
#[test]
fn datafile_mutation_set_channel_data() -> Result<(), Box<dyn std::error::Error>> {
let mut df = two_channel_datafile();
df.set_channel_data(0, ChannelData::Raw(vec![99i16, 88, 77]))?;
assert_eq!(df.channels.first().map(|c| c.point_count), Some(3));
let ch = df.channels.first().ok_or("channel 0 not present")?;
let ChannelData::Raw(ref v) = ch.data else {
return Err("expected Raw data after set_channel_data".into());
};
assert_eq!(v.as_slice(), &[99i16, 88, 77]);
Ok(())
}
#[test]
fn datafile_mutation_set_channel_data_out_of_bounds() {
let mut df = two_channel_datafile();
let result = df.set_channel_data(99, ChannelData::Raw(vec![1i16]));
assert!(result.is_err(), "out-of-bounds index must return an error");
}
#[test]
fn datafile_mutation_add_marker() {
let mut df = two_channel_datafile();
assert_eq!(df.markers.len(), 0);
df.add_marker(Marker {
label: String::from("test"),
global_sample_index: 50,
channel: None,
style: MarkerStyle::Append,
created_at: None,
});
assert_eq!(df.markers.len(), 1);
assert_eq!(df.markers.first().map(|m| m.label.as_str()), Some("test"));
}
#[test]
fn datafile_mutation_set_journal() -> Result<(), Box<dyn std::error::Error>> {
let mut df = two_channel_datafile();
assert!(df.journal.is_none());
df.set_journal("Test annotation");
let j = df.journal.as_ref().ok_or("journal should be set")?;
assert_eq!(j.as_text(), "Test annotation");
Ok(())
}
#[test]
fn datafile_mutation_then_write_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let mut df = two_channel_datafile();
df.set_channel_data(0, ChannelData::Raw(vec![99i16, 88, 77, 66]))?;
df.add_marker(Marker {
label: String::from("Modified"),
global_sample_index: 0,
channel: None,
style: MarkerStyle::Append,
created_at: None,
});
df.set_journal("Modified recording");
let bytes = write_to_bytes_with(&df, &WriteOptions::new().revision(44))?;
let parsed = ReadOptions::new().read_bytes(&bytes)?.into_value();
let ch = parsed.channels.first().ok_or("channel 0 not present")?;
let ChannelData::Raw(ref v) = ch.data else {
return Err("expected Raw data after modification round-trip".into());
};
assert_eq!(v.as_slice(), &[99i16, 88, 77, 66]);
assert_eq!(parsed.markers.len(), 1);
assert_eq!(
parsed.markers.first().map(|m| m.label.as_str()),
Some("Modified")
);
let j = parsed.journal.ok_or("journal should survive round-trip")?;
assert_eq!(j.as_text(), "Modified recording");
Ok(())
}
#[test]
fn write_zero_channels_does_not_panic() -> Result<(), Box<dyn std::error::Error>> {
let df = Datafile {
metadata: base_metadata(0, 1000.0),
channels: Vec::new(),
markers: Vec::new(),
journal: None,
};
let bytes = write_to_bytes(&df)?;
assert!(
!bytes.is_empty(),
"write of 0-channel file must produce non-empty bytes"
);
let result = ReadOptions::new().read_bytes(&bytes);
assert!(result.is_err(), "parser should reject a 0-channel file");
Ok(())
}
#[test]
fn write_stream_to_cursor() -> Result<(), Box<dyn std::error::Error>> {
let df = two_channel_datafile();
let mut buf: Vec<u8> = Vec::new();
write_stream(&df, &mut buf)?;
let parsed = ReadOptions::new().read_bytes(&buf)?.into_value();
assert_eq!(parsed.channels.len(), 2);
Ok(())
}
#[test]
fn write_options_builder_default() {
let opts = WriteOptions::default();
assert_eq!(opts.revision, 43);
assert!(!opts.compressed);
assert_eq!(opts.byte_order, ByteOrder::LittleEndian);
}
#[test]
fn write_options_builder_compressed_sets_fields() {
let opts = WriteOptions::new().compressed(true).revision(68);
assert!(opts.compressed);
assert_eq!(opts.revision, 68);
}