use falcon_mdf::{Mf4File, SignalValues};
use serde_json::Value;
use std::path::{Path, PathBuf};
const TAKE: usize = 200;
const REL_TOL: f64 = 1e-9;
fn reference_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("test_data")
.join("reference")
}
fn golden() -> Value {
serde_json::from_str(include_str!("data/reference_golden.json"))
.expect("reference_golden.json is malformed")
}
fn close(a: f64, b: f64) -> bool {
if a == b || (a.is_nan() && b.is_nan()) {
return true;
}
if a.is_infinite() || b.is_infinite() {
return false;
}
let scale = a.abs().max(b.abs()).max(1.0);
(a - b).abs() <= REL_TOL * scale
}
fn expected_number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => match s.as_str() {
"nan" => Some(f64::NAN),
"inf" => Some(f64::INFINITY),
"-inf" => Some(f64::NEG_INFINITY),
_ => None,
},
_ => None,
}
}
fn flat_groups(file: &Mf4File) -> std::collections::HashMap<(usize, usize), usize> {
let mut map = std::collections::HashMap::new();
let mut next = 0usize;
for dg in file.data_groups() {
for cg in &dg.channel_groups {
map.insert((dg.index, cg.index), next);
next += 1;
}
}
map
}
enum Decoded {
Num(Vec<f64>, usize),
Complex(Vec<f64>, Vec<f64>, usize),
Str(Vec<String>, usize),
Bytes(Vec<String>, usize),
Canopen(usize),
Failed(String),
}
fn decode(file: &Mf4File, channel: &falcon_mdf::Channel) -> Decoded {
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
match file.signal(channel).and_then(|s| s.values()) {
Ok(SignalValues::Str(v)) => {
let n = v.len();
Decoded::Str(v.into_iter().take(TAKE).collect(), n)
}
Ok(SignalValues::Bytes { data, width }) => {
let n = data.len().checked_div(width).unwrap_or(0);
Decoded::Bytes(data.chunks(width).take(TAKE).map(hex).collect(), n)
}
Ok(SignalValues::VarBytes { data, starts }) => {
let n = starts.len().saturating_sub(1);
let first = (0..n.min(TAKE))
.map(|i| hex(&data[starts[i]..starts[i + 1]]))
.collect();
Decoded::Bytes(first, n)
}
Ok(SignalValues::Complex { re, im }) => {
let n = re.len();
Decoded::Complex(
re.into_iter().take(TAKE).collect(),
im.into_iter().take(TAKE).collect(),
n,
)
}
Ok(SignalValues::CanopenDate(v)) => Decoded::Canopen(v.len()),
Ok(SignalValues::CanopenTime(v)) => Decoded::Canopen(v.len()),
Ok(other) => {
let n = other.len();
Decoded::Num(other.to_f64().into_iter().take(TAKE).collect(), n)
}
Err(e) => Decoded::Failed(e.to_string()),
}
}
struct Mismatch {
file: String,
channel: String,
detail: String,
}
impl std::fmt::Display for Mismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}: {}", self.file, self.channel, self.detail)
}
}
fn check_file(path: &Path, expected: &Value, out: &mut Vec<Mismatch>) -> usize {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let file = match Mf4File::open(path) {
Ok(f) => f,
Err(e) => {
out.push(Mismatch {
file: name,
channel: "<file>".into(),
detail: format!("would not open, but the reference reads it: {e}"),
});
return 0;
}
};
let groups = flat_groups(&file);
let channels: Vec<_> = file.channels().cloned().collect();
let recorded = expected["channels"].as_object().expect("channels object");
let mut checked = 0;
for channel in &channels {
let Some(&g) = groups.get(&(channel.data_group_index, channel.channel_group_index)) else {
continue;
};
let Some(want) = recorded.get(&format!("{g}:{}", channel.name)) else {
continue;
};
let kind = want["kind"].as_str().unwrap_or("");
if matches!(kind, "error" | "other" | "structure") {
continue;
}
let got = decode(&file, channel);
if kind == "divergence" {
if let Decoded::Failed(e) = got {
out.push(Mismatch {
file: name.clone(),
channel: channel.name.clone(),
detail: format!("a known divergence must still decode: {e}"),
});
}
continue;
}
checked += 1;
let mut fail = |detail: String| {
out.push(Mismatch {
file: name.clone(),
channel: channel.name.clone(),
detail,
})
};
let want_n = want["n"].as_u64().unwrap_or(0) as usize;
let first = want["first"].as_array();
match got {
Decoded::Failed(e) => fail(format!("failed to decode, reference reads it: {e}")),
Decoded::Canopen(n) if kind == "canopen" => {
if n != want_n {
fail(format!("sample count {n}, reference {want_n}"));
}
}
Decoded::Num(v, n) if kind == "num" => {
if n != want_n {
fail(format!("sample count {n}, reference {want_n}"));
} else if let Some(want_v) = first {
for (i, w) in want_v.iter().enumerate() {
let (Some(a), Some(b)) = (v.get(i), expected_number(w)) else {
continue;
};
if !close(*a, b) {
fail(format!("sample {i} is {a}, reference {b}"));
break;
}
}
}
}
Decoded::Str(v, n) if kind == "str" => {
if n != want_n {
fail(format!("sample count {n}, reference {want_n}"));
} else if let Some(want_v) = first {
for (i, w) in want_v.iter().enumerate() {
let (Some(a), Some(b)) = (v.get(i), w.as_str()) else {
continue;
};
if a.trim_end_matches('\0') != b.trim_end_matches('\0') {
fail(format!("sample {i} is {a:?}, reference {b:?}"));
break;
}
}
}
}
Decoded::Complex(re, im, n) if kind == "complex" => {
if n != want_n {
fail(format!("sample count {n}, reference {want_n}"));
} else {
for (part, got) in [("re", &re), ("im", &im)] {
let Some(want_v) = want[part].as_array() else {
continue;
};
for (i, w) in want_v.iter().enumerate() {
let (Some(a), Some(b)) = (got.get(i), expected_number(w)) else {
continue;
};
if !close(*a, b) {
fail(format!("sample {i} {part} is {a}, reference {b}"));
break;
}
}
}
}
}
Decoded::Bytes(v, n) if kind == "bytes" => {
if n != want_n {
fail(format!("sample count {n}, reference {want_n}"));
} else if let Some(want_v) = first {
for (i, w) in want_v.iter().enumerate() {
let (Some(a), Some(b)) = (v.get(i), w.as_str()) else {
continue;
};
if a != b {
fail(format!("sample {i} is {a}, reference {b}"));
break;
}
}
}
}
other => {
let got_kind = match other {
Decoded::Num(..) => "num",
Decoded::Complex(..) => "complex",
Decoded::Str(..) => "str",
Decoded::Bytes(..) => "bytes",
Decoded::Canopen(..) => "canopen",
Decoded::Failed(..) => "error",
};
fail(format!("decoded as {got_kind}, reference has {kind}"));
}
}
}
checked
}
#[test]
fn vendor_files_decode_to_what_an_independent_reader_reads() {
let dir = reference_dir();
if !dir.is_dir() {
eprintln!(
"skipping: no reference files. Run scripts/fetch_reference_files.sh to fetch them."
);
return;
}
let golden = golden();
let mut mismatches = Vec::new();
let mut files = 0;
let mut channels = 0;
for (name, expected) in golden.as_object().expect("golden object") {
let path = dir.join(name);
if !path.is_file() {
continue;
}
files += 1;
channels += check_file(&path, expected, &mut mismatches);
}
if files == 0 {
eprintln!("skipping: reference directory is empty");
return;
}
assert!(
mismatches.is_empty(),
"{} of {channels} channels across {files} files disagree with the reference:\n {}",
mismatches.len(),
mismatches
.iter()
.map(|m| m.to_string())
.collect::<Vec<_>>()
.join("\n ")
);
eprintln!("{channels} channels across {files} reference files agree");
}
#[test]
fn every_reference_file_opens() {
let dir = reference_dir();
if !dir.is_dir() {
eprintln!("skipping: no reference files");
return;
}
let mut refused = Vec::new();
let mut opened = 0;
let mut entries: Vec<_> = std::fs::read_dir(&dir)
.expect("reference directory")
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("mf4")))
.collect();
entries.sort();
for path in &entries {
let name = path.file_name().unwrap().to_string_lossy().to_string();
match Mf4File::open(path) {
Ok(_) => opened += 1,
Err(e) => refused.push(format!("{name}: {e}")),
}
}
assert!(
refused.is_empty(),
"{} file(s) an independent reader opens were refused:\n {}",
refused.len(),
refused.join("\n ")
);
let golden = golden();
let recorded = golden.as_object().expect("golden object");
let uncovered: Vec<_> = entries
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.filter(|n| !recorded.contains_key(n))
.collect();
assert!(
uncovered.is_empty(),
"{} fetched file(s) have no ground truth, so nothing checks their values \
— run scripts/generate_reference_golden.py:\n {}",
uncovered.len(),
uncovered.join("\n ")
);
eprintln!("{opened} reference files open, all covered by the ground truth");
}
#[test]
fn the_b35_byte_flip_repro_still_errors_cleanly_not_ballooning() {
let path = reference_dir().join("dSPACE_MeasurementArrays.mf4");
if !path.is_file() {
eprintln!(
"skipping: no reference files. Run scripts/fetch_reference_files.sh to fetch them."
);
return;
}
let mut bytes = std::fs::read(&path).expect("read dSPACE_MeasurementArrays.mf4");
bytes[1331] ^= 0xFF;
let flipped = std::env::temp_dir().join("falcon_mdf_b35_repro.mf4");
std::fs::write(&flipped, &bytes).expect("write mutated file");
let file = Mf4File::open(&flipped).expect("a mutated dim size must not refuse the whole file");
let mut any_array_checked = false;
for ch in file.channels() {
if ch.array_shape().is_none() {
continue;
}
any_array_checked = true;
let _ = file.signal(ch).and_then(|s| s.values());
}
assert!(
any_array_checked,
"the file should still expose at least one array channel to check"
);
let _ = std::fs::remove_file(&flipped);
}
#[test]
fn a_look_up_array_composed_with_another_ca_block_decodes_its_combined_shape() {
let path = reference_dir().join("Vector_MeasurementArrays.mf4");
if !path.is_file() {
eprintln!(
"skipping: no reference files. Run scripts/fetch_reference_files.sh to fetch them."
);
return;
}
let file = Mf4File::open(&path).expect("Vector_MeasurementArrays.mf4 should open");
let ch = file.find_channel("KF4").expect("KF4 should be listed");
assert_eq!(
ch.array_shape(),
Some(&[6u64, 8u64][..]),
"combined shape is the outer CA's dims followed by the inner CA's"
);
assert!(ch.unreadable().is_none(), "KF4's own elements are readable");
let values = file
.signal(ch)
.expect("signal")
.values()
.expect("KF4 should decode");
let SignalValues::Array {
values,
elements_per_sample,
} = values
else {
panic!("expected a fixed-size array");
};
assert_eq!(elements_per_sample, 48);
let row = |perturb_row_2: bool| -> Vec<f64> {
let mut out = Vec::new();
for i in 0..6u64 {
for j in 0..8u64 {
let expected = i * 10 + j;
let value = match (perturb_row_2, i, j) {
(true, 2, 4) => 42,
(true, 2, 6) => 12,
_ => expected,
};
out.push(value as f64);
}
}
out
};
let expected: Vec<f64> = [row(false), row(false), row(true), row(true)].concat();
assert_eq!(values, expected, "KF4's 4 samples, 48 elements each");
}