use std::path::{Path, PathBuf};
use ciborium::Value as CborValue;
use tensogram::{DecodeOptions, decode};
use tensogram_grib::{ConvertOptions, Grouping, convert_grib_file};
fn testdata() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata")
}
fn decode_opts() -> DecodeOptions {
DecodeOptions::default()
}
fn cbor_map_get<'a>(map: &'a CborValue, key: &str) -> Option<&'a CborValue> {
if let CborValue::Map(entries) = map {
entries.iter().find_map(|(k, v)| match k {
CborValue::Text(s) if s == key => Some(v),
_ => None,
})
} else {
None
}
}
fn get_mars_from_base(meta: &tensogram::GlobalMetadata) -> &CborValue {
meta.base
.first()
.expect("base must have at least one entry")
.get("mars")
.expect("base[0] must contain 'mars' key")
}
#[test]
fn test_lsm_convert() {
let path = testdata().join("lsm.grib2");
let opts = ConvertOptions::default(); let messages = convert_grib_file(&path, &opts).expect("convert lsm.grib2");
assert_eq!(messages.len(), 1, "single GRIB -> single Tensogram message");
let (meta, objects) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(objects.len(), 1, "one data object");
let mars = get_mars_from_base(&meta);
assert!(cbor_map_get(mars, "class").is_some(), "missing mars.class");
assert!(cbor_map_get(mars, "type").is_some(), "missing mars.type");
assert!(
cbor_map_get(mars, "stream").is_some(),
"missing mars.stream"
);
assert!(
cbor_map_get(mars, "expver").is_some(),
"missing mars.expver"
);
let has_param = cbor_map_get(mars, "param").is_some()
|| cbor_map_get(mars, "shortName").is_some()
|| cbor_map_get(mars, "paramId").is_some();
assert!(has_param, "no parameter identification key in mars");
assert_eq!(
cbor_map_get(mars, "grid"),
Some(&CborValue::Text("regular_ll".to_string())),
"mars.grid should be 'regular_ll'"
);
let (desc, _) = &objects[0];
assert_eq!(desc.ndim, 2);
assert_eq!(desc.shape, vec![721, 1440]);
}
#[test]
fn test_2t_round_trip() {
let path = testdata().join("2t.grib2");
let opts = ConvertOptions::default();
let messages = convert_grib_file(&path, &opts).expect("convert 2t.grib2");
let (_, objects) = decode(&messages[0], &decode_opts()).expect("decode");
let (desc, raw_bytes) = &objects[0];
assert_eq!(desc.dtype, tensogram::Dtype::Float64);
let expected_elements: usize = desc.shape.iter().product::<u64>() as usize;
assert_eq!(
raw_bytes.len(),
expected_elements * 8,
"decoded byte count = elements * 8"
);
let values: Vec<f64> = raw_bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
assert!(
values.iter().all(|v| v.is_finite()),
"all 2t values should be finite"
);
assert!(
values.iter().any(|&v| v > 200.0),
"2t values should contain temperatures > 200 K"
);
}
#[test]
fn test_q_pl_convert() {
let path = testdata().join("q_150.grib2");
let opts = ConvertOptions::default();
let messages = convert_grib_file(&path, &opts).expect("convert q_150.grib2");
let (meta, objects) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(objects.len(), 1);
let mars = get_mars_from_base(&meta);
let has_level =
cbor_map_get(mars, "levelist").is_some() || cbor_map_get(mars, "level").is_some();
assert!(has_level, "missing level key for pressure-level data");
let (desc, _) = &objects[0];
assert_eq!(desc.ndim, 2);
assert_eq!(desc.shape, vec![721, 1440]);
}
#[test]
fn test_t_pl_round_trip() {
let path = testdata().join("t_600.grib2");
let opts = ConvertOptions::default();
let messages = convert_grib_file(&path, &opts).expect("convert t_600.grib2");
let (_, objects) = decode(&messages[0], &decode_opts()).expect("decode");
let (desc, raw_bytes) = &objects[0];
let expected_elements: usize = desc.shape.iter().product::<u64>() as usize;
assert_eq!(raw_bytes.len(), expected_elements * 8);
let values: Vec<f64> = raw_bytes
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().unwrap()))
.collect();
assert!(values.iter().all(|v| v.is_finite()));
assert!(
values.iter().any(|&v| v > 200.0 && v < 320.0),
"t at 600 hPa should have values in [200, 320] K range"
);
}
#[test]
fn test_multi_merge() {
let combined = make_combined_grib(&["lsm.grib2", "2t.grib2"]);
let opts = ConvertOptions {
grouping: Grouping::MergeAll,
..ConvertOptions::default()
};
let messages = convert_grib_file(combined.path(), &opts).expect("convert merged");
assert_eq!(messages.len(), 1, "MergeAll -> 1 Tensogram message");
let (meta, objects) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(objects.len(), 2, "2 GRIB messages -> 2 data objects");
assert_eq!(meta.base.len(), 2, "base should have 2 entries");
for (i, entry) in meta.base.iter().enumerate() {
assert!(entry.contains_key("mars"), "base[{i}] must have mars");
}
for (i, entry) in meta.base.iter().enumerate() {
assert!(
entry.contains_key("_reserved_"),
"base[{i}] must have _reserved_"
);
}
for (i, entry) in meta.base.iter().enumerate() {
let mars = entry.get("mars").expect("mars key");
assert!(
cbor_map_get(mars, "class").is_some(),
"base[{i}] mars.class should be present"
);
}
}
#[test]
fn test_multi_split() {
let combined = make_combined_grib(&["lsm.grib2", "2t.grib2"]);
let opts = ConvertOptions {
grouping: Grouping::OneToOne,
..ConvertOptions::default()
};
let messages = convert_grib_file(combined.path(), &opts).expect("convert split");
assert_eq!(messages.len(), 2, "OneToOne -> 2 Tensogram messages");
for (i, msg_bytes) in messages.iter().enumerate() {
let (meta, objects) = decode(msg_bytes, &decode_opts()).expect("decode");
assert_eq!(objects.len(), 1, "message {} has 1 object", i);
assert!(
meta.base.first().is_some_and(|e| e.contains_key("mars")),
"message {} base[0] must have mars",
i
);
}
}
#[test]
fn test_base_entry_metadata() {
let path = testdata().join("2t.grib2");
let opts = ConvertOptions::default();
let messages = convert_grib_file(&path, &opts).expect("convert 2t.grib2");
let (meta, _) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(meta.base.len(), 1, "single object -> one base entry");
let entry = &meta.base[0];
let reserved = entry.get("_reserved_").expect("must have _reserved_");
let tensor = cbor_map_get(reserved, "tensor").expect("must have tensor");
assert!(cbor_map_get(tensor, "ndim").is_some(), "must have ndim");
assert!(cbor_map_get(tensor, "shape").is_some(), "must have shape");
assert!(
cbor_map_get(tensor, "strides").is_some(),
"must have strides"
);
assert_eq!(
cbor_map_get(tensor, "dtype"),
Some(&CborValue::Text("float64".to_string())),
"dtype should be 'float64'"
);
let mars = get_mars_from_base(&meta);
assert_eq!(
cbor_map_get(mars, "grid"),
Some(&CborValue::Text("regular_ll".to_string())),
"mars.grid should be 'regular_ll'"
);
}
#[test]
fn test_all_keys_single_object() {
let path = testdata().join("2t.grib2");
let opts = ConvertOptions {
preserve_all_keys: true,
..ConvertOptions::default()
};
let messages = convert_grib_file(&path, &opts).expect("convert 2t.grib2 all-keys");
let (meta, _) = decode(&messages[0], &decode_opts()).expect("decode");
let entry = &meta.base[0];
assert!(entry.contains_key("mars"), "must have mars");
let grib = entry
.get("grib")
.expect("base[0] must contain 'grib' when preserve_all_keys is on");
let grib_map = match grib {
CborValue::Map(m) => m,
other => panic!("grib should be a map, got: {:?}", other),
};
let ns_names: Vec<&str> = grib_map
.iter()
.filter_map(|(k, _)| match k {
CborValue::Text(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert!(ns_names.contains(&"geography"), "must have geography ns");
assert!(ns_names.contains(&"time"), "must have time ns");
assert!(ns_names.contains(&"parameter"), "must have parameter ns");
let geo = cbor_map_get(grib, "geography").expect("geography ns");
let ni = cbor_map_get(geo, "Ni");
assert_eq!(
ni,
Some(&CborValue::Integer(1440.into())),
"geography.Ni should be 1440"
);
}
#[test]
fn test_all_keys_multi_merge() {
let combined = make_combined_grib(&["lsm.grib2", "2t.grib2"]);
let opts = ConvertOptions {
grouping: Grouping::MergeAll,
preserve_all_keys: true,
..ConvertOptions::default()
};
let messages = convert_grib_file(combined.path(), &opts).expect("convert all-keys merged");
let (meta, objects) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(objects.len(), 2);
for (i, entry) in meta.base.iter().enumerate() {
assert!(entry.contains_key("grib"), "base[{i}] must have grib");
let grib = entry.get("grib").unwrap();
assert!(
cbor_map_get(grib, "geography").is_some(),
"base[{i}] grib.geography should be present"
);
}
for (i, entry) in meta.base.iter().enumerate() {
let grib = entry.get("grib").unwrap();
assert!(
cbor_map_get(grib, "parameter").is_some(),
"base[{i}] grib.parameter should be present"
);
}
}
#[test]
fn test_all_keys_off_no_grib() {
let path = testdata().join("2t.grib2");
let opts = ConvertOptions::default(); let messages = convert_grib_file(&path, &opts).expect("convert 2t.grib2");
let (meta, _) = decode(&messages[0], &decode_opts()).expect("decode");
let entry = &meta.base[0];
assert!(
!entry.contains_key("grib"),
"default options must not produce 'grib' key"
);
assert!(entry.contains_key("mars"), "mars must always be present");
}
fn make_combined_grib(filenames: &[&str]) -> tempfile::NamedTempFile {
use std::io::Write;
let mut combined = tempfile::NamedTempFile::new().expect("create temp file");
for name in filenames {
let data = std::fs::read(testdata().join(name)).expect("read fixture");
combined.write_all(&data).expect("write to temp file");
}
combined.flush().expect("flush");
combined
}
#[test]
fn test_one_to_one_preserve_all_keys() {
let path = testdata().join("2t.grib2");
let opts = ConvertOptions {
grouping: Grouping::OneToOne,
preserve_all_keys: true,
..ConvertOptions::default()
};
let messages = convert_grib_file(&path, &opts).expect("convert 2t.grib2 one-to-one all-keys");
assert_eq!(messages.len(), 1, "single GRIB -> single Tensogram message");
let (meta, objects) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(objects.len(), 1);
assert_eq!(meta.base.len(), 1);
let entry = &meta.base[0];
assert!(entry.contains_key("mars"), "base[0] must have mars");
assert!(
entry.contains_key("grib"),
"base[0] must have grib with preserve_all_keys"
);
let grib = entry.get("grib").unwrap();
assert!(
cbor_map_get(grib, "geography").is_some(),
"grib.geography must be present"
);
}
#[test]
fn test_one_to_one_no_preserve_all_keys() {
let path = testdata().join("lsm.grib2");
let opts = ConvertOptions {
grouping: Grouping::OneToOne,
preserve_all_keys: false,
..ConvertOptions::default()
};
let messages = convert_grib_file(&path, &opts).expect("convert lsm.grib2 one-to-one");
assert_eq!(messages.len(), 1);
let (meta, _) = decode(&messages[0], &decode_opts()).expect("decode");
let entry = &meta.base[0];
assert!(entry.contains_key("mars"), "mars must be present");
assert!(
!entry.contains_key("grib"),
"grib must NOT be present without preserve_all_keys"
);
}
#[test]
fn test_merge_all_independent_base_entries() {
let combined = make_combined_grib(&["lsm.grib2", "2t.grib2"]);
let opts = ConvertOptions::default();
let messages = convert_grib_file(combined.path(), &opts).expect("convert merged");
let (meta, _) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(meta.base.len(), 2);
for (i, entry) in meta.base.iter().enumerate() {
let mars = entry
.get("mars")
.unwrap_or_else(|| panic!("base[{i}] must have mars"));
assert!(
cbor_map_get(mars, "class").is_some(),
"base[{i}] mars.class must be independently present"
);
assert!(
cbor_map_get(mars, "grid").is_some(),
"base[{i}] mars.grid must be independently present"
);
}
let mars0 = meta.base[0].get("mars").unwrap();
let mars1 = meta.base[1].get("mars").unwrap();
let param0 = cbor_map_get(mars0, "param").or(cbor_map_get(mars0, "shortName"));
let param1 = cbor_map_get(mars1, "param").or(cbor_map_get(mars1, "shortName"));
assert_ne!(
param0, param1,
"lsm and 2t should have different parameter identification"
);
}
#[test]
fn test_one_to_one_multi_grib_messages() {
let combined = make_combined_grib(&["lsm.grib2", "2t.grib2", "q_150.grib2", "t_600.grib2"]);
let opts = ConvertOptions {
grouping: Grouping::OneToOne,
..ConvertOptions::default()
};
let messages = convert_grib_file(combined.path(), &opts).expect("convert 4 GRIBs");
assert_eq!(messages.len(), 4, "OneToOne with 4 GRIBs -> 4 messages");
for (i, msg_bytes) in messages.iter().enumerate() {
let (meta, objects) = decode(msg_bytes, &decode_opts()).expect("decode");
assert_eq!(objects.len(), 1, "message {i} has 1 object");
assert_eq!(meta.base.len(), 1, "message {i} has 1 base entry");
let entry = &meta.base[0];
assert!(
entry.contains_key("mars"),
"message {i} base[0] must have mars"
);
assert!(
entry.contains_key("_reserved_"),
"message {i} base[0] must have _reserved_"
);
let reserved = entry.get("_reserved_").unwrap();
let tensor = cbor_map_get(reserved, "tensor").expect("must have tensor");
assert!(
cbor_map_get(tensor, "ndim").is_some(),
"message {i} must have ndim"
);
}
}
#[test]
fn test_merge_all_preserve_all_keys_multi() {
let combined = make_combined_grib(&["lsm.grib2", "2t.grib2", "q_150.grib2", "t_600.grib2"]);
let opts = ConvertOptions {
grouping: Grouping::MergeAll,
preserve_all_keys: true,
..ConvertOptions::default()
};
let messages = convert_grib_file(combined.path(), &opts).expect("convert 4 GRIBs merged");
assert_eq!(messages.len(), 1);
let (meta, objects) = decode(&messages[0], &decode_opts()).expect("decode");
assert_eq!(objects.len(), 4, "4 GRIB -> 4 data objects");
assert_eq!(meta.base.len(), 4, "4 base entries");
for (i, entry) in meta.base.iter().enumerate() {
assert!(entry.contains_key("mars"), "base[{i}] must have mars");
assert!(entry.contains_key("grib"), "base[{i}] must have grib");
assert!(
entry.contains_key("_reserved_"),
"base[{i}] must have _reserved_"
);
}
}
#[test]
fn test_2d_strides_correct() {
let path = testdata().join("2t.grib2");
let opts = ConvertOptions::default();
let messages = convert_grib_file(&path, &opts).expect("convert");
let (_, objects) = decode(&messages[0], &decode_opts()).expect("decode");
let (desc, _) = &objects[0];
assert_eq!(desc.shape, vec![721, 1440]);
assert_eq!(desc.strides, vec![1440, 1]);
}
#[test]
fn test_empty_grib_file_error() {
use std::io::Write;
let mut empty = tempfile::NamedTempFile::new().expect("create temp file");
empty.write_all(b"not a grib file").expect("write");
empty.flush().expect("flush");
let opts = ConvertOptions::default();
let result = convert_grib_file(empty.path(), &opts);
assert!(result.is_err(), "empty/invalid GRIB file should fail");
}
fn mars_area_as_f64s(mars: &CborValue) -> Option<[f64; 4]> {
let CborValue::Array(items) = cbor_map_get(mars, "area")? else {
return None;
};
if items.len() != 4 {
return None;
}
let mut out = [0.0; 4];
for (i, item) in items.iter().enumerate() {
let CborValue::Float(f) = item else {
return None;
};
out[i] = *f;
}
Some(out)
}
#[test]
fn test_mars_area_regular_ll_emitted() {
let fixtures = ["lsm.grib2", "2t.grib2", "q_150.grib2", "t_600.grib2"];
let expected = [90.0_f64, -180.0, -90.0, 179.75];
for name in fixtures {
let path = testdata().join(name);
let messages =
convert_grib_file(&path, &ConvertOptions::default()).expect("convert fixture");
let (meta, _) = decode(&messages[0], &decode_opts()).expect("decode");
let mars = get_mars_from_base(&meta);
let area = mars_area_as_f64s(mars)
.unwrap_or_else(|| panic!("{name}: mars.area missing or not [f64; 4]"));
assert_eq!(area, expected, "{name}: mars.area mismatch");
}
}
#[test]
fn test_mars_area_normalises_dateline_first() {
let path = testdata().join("lsm.grib2");
let messages = convert_grib_file(&path, &ConvertOptions::default()).expect("convert lsm.grib2");
let (meta, _) = decode(&messages[0], &decode_opts()).expect("decode");
let area = mars_area_as_f64s(get_mars_from_base(&meta)).expect("mars.area present");
assert_eq!(
area[1], -180.0,
"W must be normalised from raw 180° to -180° for the full-global \
dateline-first convention",
);
assert!(
area[1] < area[3],
"after normalisation, W (area[1]) < E (area[3])",
);
}
#[test]
fn test_mars_area_emitted_in_one_to_one_grouping() {
let combined = make_combined_grib(&["lsm.grib2", "2t.grib2"]);
let opts = ConvertOptions {
grouping: Grouping::OneToOne,
..ConvertOptions::default()
};
let messages = convert_grib_file(combined.path(), &opts).expect("convert combined fixture");
assert_eq!(messages.len(), 2);
for (i, message) in messages.iter().enumerate() {
let (meta, _) = decode(message, &decode_opts()).expect("decode");
let mars = get_mars_from_base(&meta);
let area =
mars_area_as_f64s(mars).unwrap_or_else(|| panic!("message {i}: mars.area missing"));
assert_eq!(area, [90.0, -180.0, -90.0, 179.75]);
}
}