use std::collections::BTreeMap;
use process_mining::core::event_data::object_centric::ocel_csv::{
export_ocel_csv_to_string, import_ocel_csv,
};
use process_mining::core::event_data::object_centric::{
OCELAttributeValue, OCELEvent, OCELEventAttribute, OCELObject, OCELObjectAttribute,
OCELRelationship, OCELType, OCELTypeAttribute, OCEL,
};
#[cfg(feature = "ocel-bundle")]
use process_mining::core::event_data::object_centric::ocel_bundle::{
export_ocel_bundle, import_ocel_bundle, BundleExportOptions, ContainerLayout, StorageFormat,
};
fn log_named(text: &str) -> OCEL {
let event_type = format!("ev {text}");
let object_type = format!("ob {text}");
OCEL {
event_types: vec![OCELType {
name: event_type.clone(),
attributes: vec![OCELTypeAttribute {
name: format!("ea {text}"),
value_type: "string".into(),
}],
}],
object_types: vec![OCELType {
name: object_type.clone(),
attributes: vec![OCELTypeAttribute {
name: format!("oa {text}"),
value_type: "string".into(),
}],
}],
events: vec![OCELEvent {
id: format!("e {text}"),
event_type,
time: "2024-01-01T10:00:00+00:00".parse().unwrap(),
attributes: vec![OCELEventAttribute {
name: format!("ea {text}"),
value: OCELAttributeValue::String(format!("val {text}")),
}],
relationships: vec![OCELRelationship {
object_id: format!("o {text}"),
qualifier: format!("q {text}"),
}],
}],
objects: vec![OCELObject {
id: format!("o {text}"),
object_type,
attributes: vec![OCELObjectAttribute {
name: format!("oa {text}"),
value: OCELAttributeValue::String(format!("val {text}")),
time: "2024-01-02T10:00:00+00:00".parse().unwrap(),
}],
relationships: vec![],
}],
}
}
fn log_valued(value: &str) -> OCEL {
let mut ocel = log_named("v");
ocel.events[0].attributes[0].value = OCELAttributeValue::String(value.to_string());
ocel
}
fn facts(ocel: &OCEL) -> Vec<String> {
let mut out = Vec::new();
for t in &ocel.event_types {
out.push(format!("event type {:?}", t.name));
}
for t in &ocel.object_types {
out.push(format!("object type {:?}", t.name));
}
for e in &ocel.events {
out.push(format!("event {:?} of {:?}", e.id, e.event_type));
for a in &e.attributes {
out.push(format!("event {:?} {:?} = {:?}", e.id, a.name, a.value));
}
for r in &e.relationships {
out.push(format!(
"e2o {:?} -> {:?} [{:?}]",
e.id, r.object_id, r.qualifier
));
}
}
for o in &ocel.objects {
out.push(format!("object {:?} of {:?}", o.id, o.object_type));
for a in &o.attributes {
out.push(format!("object {:?} {:?} = {:?}", o.id, a.name, a.value));
}
}
out.sort();
out
}
fn through_csv(ocel: &OCEL) -> OCEL {
let text = export_ocel_csv_to_string(ocel).expect("csv export");
import_ocel_csv(text.as_bytes()).unwrap_or_else(|e| panic!("csv import: {e}\n---\n{text}"))
}
#[cfg(feature = "ocel-bundle")]
fn through_bundle(ocel: &OCEL, label: &str) -> OCEL {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join(label);
std::fs::create_dir_all(&target).expect("mkdir");
export_ocel_bundle(
ocel,
&target,
BundleExportOptions {
layout: ContainerLayout::Directory,
storage: if cfg!(feature = "ocel-bundle-parquet") {
StorageFormat::Parquet
} else {
StorageFormat::Csv
},
},
)
.expect("bundle export");
import_ocel_bundle(&target).expect("bundle import")
}
const ORDINARY: &[(&str, &str)] = &[
("comma", "a,b"),
("quote", "a\"b"),
("newline", "a\nb"),
("tab", "a\tb"),
("unicode", "Ünïcödé"),
("percent", "a%2Fb"),
("semicolon", "a;b"),
];
const RESERVED: &[(&str, &str)] = &[
("slash", "a/b"),
("hash", "a#b"),
("brace", "a{b"),
("backslash", "a\\b"),
("all four", "a/b#c{d\\e"),
("only separators", "/#{"),
];
#[test]
fn the_csv_gives_back_ordinary_text_unchanged() {
for (label, text) in ORDINARY {
let src = log_named(text);
assert_eq!(facts(&src), facts(&through_csv(&src)), "{label}");
}
}
#[test]
fn the_csv_gives_back_its_own_reserved_characters() {
for (label, text) in RESERVED {
let src = log_named(text);
assert_eq!(facts(&src), facts(&through_csv(&src)), "{label}");
}
}
#[test]
fn a_header_that_opens_with_multi_byte_text_is_read_not_split() {
let csv = "id,activity,timestamp,Ünïcödé,ot:日本\n\
e1,open,2024-01-01T10:00:00+0000,vÄ,o1";
let ocel = import_ocel_csv(csv.as_bytes()).expect("import");
assert_eq!(ocel.objects[0].object_type, "日本");
assert_eq!(ocel.events[0].attributes[0].name, "Ünïcödé");
assert_eq!(
ocel.events[0].attributes[0].value,
OCELAttributeValue::String("vÄ".into())
);
}
#[test]
fn an_unescaped_backslash_in_a_hand_written_file_is_still_a_backslash() {
let csv = "id,activity,timestamp,ot:file\n\
e1,open,2024-01-01T10:00:00+0000,C:\\Users\\me";
let ocel = import_ocel_csv(csv.as_bytes()).expect("import");
assert_eq!(ocel.objects.len(), 1);
assert_eq!(ocel.objects[0].id, "C:\\Users\\me");
}
#[test]
fn text_that_only_looks_numeric_stays_text() {
for value in [
"007", "+7", "1e3", "1_2", "0x1f", "5.", ".5", "-0", "123456789012345678901234567890", "2024-01-01", "2022-05-04 05:57:00", "Ünïcödé",
"aÄ",
"日本",
"aaaa̋",
] {
let src = log_valued(value);
let back = through_csv(&src);
assert_eq!(
back.events[0].attributes[0].value,
OCELAttributeValue::String(value.to_string()),
"{value:?} should have stayed a string"
);
}
}
#[test]
fn text_that_is_a_number_is_read_as_one() {
let cases: BTreeMap<&str, OCELAttributeValue> = [
("7", OCELAttributeValue::Integer(7)),
("-12", OCELAttributeValue::Integer(-12)),
("0", OCELAttributeValue::Integer(0)),
("0.5", OCELAttributeValue::Float(0.5)),
("-0.5", OCELAttributeValue::Float(-0.5)),
("-12.75", OCELAttributeValue::Float(-12.75)),
("5.00", OCELAttributeValue::Float(5.0)),
("true", OCELAttributeValue::Boolean(true)),
("TRUE", OCELAttributeValue::Boolean(true)),
]
.into_iter()
.collect();
for (text, expected) in cases {
let src = log_valued(text);
let back = through_csv(&src);
assert_eq!(
back.events[0].attributes[0].value, expected,
"{text:?} should have been read as {expected:?}"
);
}
}
#[test]
fn the_csv_keeps_the_whitespace_the_format_tells_it_to_keep() {
let mut src = log_named("x");
src.object_types[0].name = "ob trailing ".into();
src.objects[0].object_type = "ob trailing ".into();
src.events[0].attributes[0].value = OCELAttributeValue::String(" padded ".into());
let back = through_csv(&src);
assert_eq!(back.objects[0].object_type, "ob trailing ");
assert_eq!(
back.events[0].attributes[0].value,
OCELAttributeValue::String(" padded ".into())
);
}
#[test]
fn the_csv_trims_the_three_things_the_format_says_it_may() {
let mut src = log_named("x");
src.events[0].id = "e trailing ".into();
src.events[0].event_type = "ev trailing ".into();
src.event_types[0].name = "ev trailing ".into();
src.events[0].relationships[0].qualifier = "q trailing ".into();
let back = through_csv(&src);
assert_eq!(back.events[0].id, "e trailing");
assert_eq!(back.events[0].event_type, "ev trailing");
assert_eq!(back.events[0].relationships[0].qualifier, "q trailing");
}
#[test]
fn strict_rejects_an_unknown_o2o_source_without_needing_verbose() {
let csv = "id,activity,timestamp,ot:item\n\
ghost,o2o,,i1#has";
let strict = process_mining::core::event_data::object_centric::ocel_csv::OCELCSVImportOptions {
strict: true,
verbose: false,
..Default::default()
};
let err =
process_mining::core::event_data::object_centric::ocel_csv::import_ocel_csv_with_options(
csv.as_bytes(),
&strict,
);
assert!(err.is_err(), "strict should reject an unknown o2o source");
let lenient = import_ocel_csv(csv.as_bytes()).expect("lenient import");
assert!(lenient.objects.iter().all(|o| o.relationships.is_empty()));
}
#[test]
fn an_empty_string_attribute_does_not_survive_the_csv() {
let src = log_valued("");
let back = through_csv(&src);
assert!(back.events[0].attributes.is_empty());
}
#[cfg(feature = "ocel-bundle")]
#[test]
fn the_bundle_gives_everything_back_exactly() {
for (label, text) in ORDINARY.iter().chain(RESERVED) {
let src = log_named(text);
assert_eq!(
facts(&src),
facts(&through_bundle(&src, "names")),
"{label}"
);
}
for text in ["trailing ", " leading", " ", "007", ""] {
let mut src = log_named("x");
src.events[0].attributes[0].value = OCELAttributeValue::String(text.to_string());
src.objects[0].attributes[0].value = OCELAttributeValue::String(text.to_string());
assert_eq!(
facts(&src),
facts(&through_bundle(&src, "values")),
"value {text:?}"
);
}
let mut src = log_named("x");
src.events[0].id = "e trailing ".into();
src.events[0].event_type = "ev trailing ".into();
src.event_types[0].name = "ev trailing ".into();
src.events[0].relationships[0].qualifier = "q trailing ".into();
assert_eq!(facts(&src), facts(&through_bundle(&src, "whitespace")));
}