use std::collections::HashMap;
use std::fs;
use wasm4pm::models::EventLog;
const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
fn load_event_log_json(name: &str) -> EventLog {
let path = format!("{FIXTURES_DIR}/{name}");
let json_str = fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to load fixture {}: {}", path, e));
serde_json::from_str(&json_str)
.unwrap_or_else(|e| panic!("Failed to parse event log JSON from {}: {}", path, e))
}
fn compute_activity_bottleneck_scores(log: &EventLog, activity_key: &str) -> Vec<(String, f64)> {
let mut activity_counts: HashMap<String, usize> = HashMap::new();
for trace in &log.traces {
for event in &trace.events {
if let Some(wasm4pm::models::AttributeValue::String(name)) =
event.attributes.get(activity_key)
{
*activity_counts.entry(name.clone()).or_insert(0) += 1;
}
}
}
let mut scores: Vec<(String, f64)> = activity_counts
.into_iter()
.map(|(activity, count)| (activity, count as f64))
.collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scores
}
fn compute_activity_gap_bottleneck(
log: &EventLog,
activity_key: &str,
timestamp_key: &str,
) -> Vec<(String, f64)> {
let mut gap_sums: HashMap<String, f64> = HashMap::new();
let mut gap_counts: HashMap<String, usize> = HashMap::new();
for trace in &log.traces {
let mut last_activity: Option<String> = None;
let mut last_ts: Option<u64> = None;
for event in &trace.events {
let activity_name = event.attributes.get(activity_key).and_then(|v| match v {
wasm4pm::models::AttributeValue::String(s) => Some(s.clone()),
_ => None,
});
let ts = event.attributes.get(timestamp_key).and_then(|v| match v {
wasm4pm::models::AttributeValue::Date(s) => parse_timestamp_ms(s),
wasm4pm::models::AttributeValue::String(s) => parse_timestamp_ms(s),
wasm4pm::models::AttributeValue::Int(i) => Some(*i as u64),
_ => None,
});
if let (Some(name), Some(ts)) = (activity_name, ts) {
if let (Some(prev_name), Some(prev_ts)) = (&last_activity, last_ts) {
if prev_name == &name {
let gap = (ts - prev_ts) as f64;
*gap_sums.entry(name.clone()).or_insert(0.0) += gap;
*gap_counts.entry(name.clone()).or_insert(0) += 1;
}
}
last_activity = Some(name);
last_ts = Some(ts);
}
}
}
let mut avg_gaps: Vec<(String, f64)> = gap_sums
.into_iter()
.filter_map(|(name, sum)| {
let count = gap_counts.get(&name)?;
if *count > 0 {
Some((name, sum / *count as f64))
} else {
None
}
})
.collect();
avg_gaps.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
avg_gaps
}
fn parse_timestamp_ms(ts: &str) -> Option<u64> {
if let Ok(ms) = ts.parse::<u64>() {
return Some(ms);
}
let ts_clean = ts.trim();
if ts_clean.len() >= 19 {
if let Ok(year) = ts_clean[0..4].parse::<u64>() {
if let Ok(month) = ts_clean[5..7].parse::<u64>() {
if let Ok(day) = ts_clean[8..10].parse::<u64>() {
if let Ok(hour) = ts_clean[11..13].parse::<u64>() {
if let Ok(min) = ts_clean[14..16].parse::<u64>() {
if let Ok(sec) = ts_clean[17..19].parse::<u64>() {
let days_from_epoch =
(year * 365 + month * 30 + day) * 86400 * 1000
+ hour * 3600 * 1000
+ min * 60 * 1000
+ sec * 1000;
return Some(days_from_epoch);
}
}
}
}
}
}
}
None
}
#[test]
fn test_real_log_bottleneck_scores_non_empty() {
let log = load_event_log_json("running-example.json");
let scores = compute_activity_bottleneck_scores(&log, "activity");
assert!(
!scores.is_empty(),
"Real log should produce at least 1 bottleneck score, got 0"
);
for i in 1..scores.len() {
assert!(
scores[i].1 <= scores[i - 1].1,
"Bottleneck scores should be sorted descending: \
{} ({:.1}) <= {} ({:.1})",
scores[i].0,
scores[i].1,
scores[i - 1].0,
scores[i - 1].1,
);
}
}
#[test]
fn test_real_log_all_activities_scored() {
let log = load_event_log_json("running-example.json");
let scores = compute_activity_bottleneck_scores(&log, "activity");
let mut activities: std::collections::HashSet<String> = std::collections::HashSet::new();
for trace in &log.traces {
for event in &trace.events {
if let Some(wasm4pm::models::AttributeValue::String(name)) =
event.attributes.get("activity")
{
activities.insert(name.clone());
}
}
}
let scored_activities: std::collections::HashSet<String> =
scores.into_iter().map(|(a, _)| a).collect();
for activity in &activities {
assert!(
scored_activities.contains(activity),
"Activity '{}' should appear in bottleneck scores but was missing",
activity,
);
}
}
#[test]
fn test_real_log_bottleneck_scores_finite() {
let log = load_event_log_json("running-example.json");
let scores = compute_activity_bottleneck_scores(&log, "activity");
for (activity, score) in &scores {
assert!(
score.is_finite(),
"Bottleneck score for '{}' should be finite, got {}",
activity,
score,
);
assert!(
*score >= 0.0,
"Bottleneck score for '{}' should be non-negative, got {}",
activity,
score,
);
}
}
#[test]
fn test_real_log_gap_bottleneck_non_empty() {
let log = load_event_log_json("running-example.json");
let gap_scores = compute_activity_gap_bottleneck(&log, "activity", "timestamp");
for (activity, avg_gap) in &gap_scores {
assert!(
avg_gap.is_finite(),
"Gap score for '{}' should be finite, got {}",
activity,
avg_gap,
);
}
}
#[test]
fn test_synthetic_log_clear_bottleneck() {
let json = r#"{
"attributes": {},
"traces": [{
"attributes": {"case:concept:name": {"tag": "String", "value": "1"}},
"events": [
{"attributes": {"activity": {"tag": "String", "value": "A"}}},
{"attributes": {"activity": {"tag": "String", "value": "B"}}},
{"attributes": {"activity": {"tag": "String", "value": "SLOW"}}},
{"attributes": {"activity": {"tag": "String", "value": "SLOW"}}},
{"attributes": {"activity": {"tag": "String", "value": "SLOW"}}},
{"attributes": {"activity": {"tag": "String", "value": "C"}}}
]
}]
}"#;
let log: EventLog = serde_json::from_str(json).unwrap();
let scores = compute_activity_bottleneck_scores(&log, "activity");
assert!(
scores.len() == 4,
"Should have 4 unique activities, got {}",
scores.len(),
);
assert_eq!(
scores[0].0, "SLOW",
"Top bottleneck should be 'SLOW' (3 events), got '{}'",
scores[0].0,
);
}