mod event_action;
mod event_log;
mod event_value;
mod state;
pub use event_action::EventAction;
pub use event_log::EventLog;
pub use event_value::Event;
pub use state::State;
#[cfg(test)]
pub mod strategy {
use super::*;
use crate::domain::model::temporal::timestamp::strategy::timestamp;
use proptest::prelude::*;
pub fn event_action() -> impl Strategy<Value = EventAction> {
use super::state::strategy::state;
prop_oneof![
state().prop_map(|s| EventAction::Created { state: s }),
(state(), state()).prop_map(|(from, to)| EventAction::StatusChanged { from, to }),
]
}
pub fn event() -> impl Strategy<Value = Event> {
(timestamp(), event_action()).prop_map(|(timestamp, action)| Event { timestamp, action })
}
pub fn event_log() -> impl Strategy<Value = EventLog> {
proptest::collection::vec(event(), 0..5).prop_map(EventLog::from_iter)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::temporal::duration::Duration;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::model::temporal::timestamp::Timestamp;
use proptest::prelude::*;
fn st(s: &str) -> State {
State::new(s).unwrap()
}
fn is_terminal(s: &str) -> bool {
s == "closed"
}
fn is_ongoing(s: &str) -> bool {
s == "in-progress"
}
fn is_stalled(s: &str) -> bool {
s == "blocked" || s == "review"
}
fn created(ts: &str, state: &str) -> Event {
Event {
timestamp: Timestamp::new(ts).unwrap(),
action: EventAction::Created { state: st(state) },
}
}
fn changed(ts: &str, from: &str, to: &str) -> Event {
Event {
timestamp: Timestamp::new(ts).unwrap(),
action: EventAction::StatusChanged {
from: st(from),
to: st(to),
},
}
}
#[test]
fn is_created_and_is_status_changed() {
let c = EventAction::Created { state: st("open") };
assert!(c.is_created());
assert!(!c.is_status_changed());
let sc = EventAction::StatusChanged {
from: st("open"),
to: st("closed"),
};
assert!(sc.is_status_changed());
assert!(!sc.is_created());
}
#[test]
fn as_str_variants() {
assert_eq!(
EventAction::Created { state: st("open") }.as_str(),
"created"
);
assert_eq!(
EventAction::StatusChanged {
from: st("open"),
to: st("closed"),
}
.as_str(),
"status_changed"
);
}
#[test]
fn event_log_push_and_len() {
let mut log = EventLog::new();
log.push(created("2026-03-11T00:00:00Z", "open"));
assert_eq!(log.len(), 1);
}
#[test]
fn event_log_creation_date() {
let mut log = EventLog::new();
log.push(created("2026-03-10T08:00:00Z", "open"));
assert_eq!(
log.creation_date(&IsoDate::new("2026-01-01").unwrap()),
IsoDate::new("2026-03-10").unwrap()
);
}
#[test]
fn event_log_last_activity_date() {
let mut log = EventLog::new();
log.push(created("2026-03-05T00:00:00Z", "open"));
log.push(changed("2026-03-15T12:00:00Z", "open", "closed"));
assert_eq!(
log.last_activity_date(&IsoDate::new("2026-01-01").unwrap()),
IsoDate::new("2026-03-15").unwrap()
);
}
#[test]
fn latest_state_returns_none_for_empty_log() {
assert!(EventLog::new().latest_state().is_none());
}
#[test]
fn latest_state_returns_created_status_when_no_transition() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
assert_eq!(log.latest_state().map(|s| s.as_str()), Some("open"));
}
#[test]
fn latest_state_returns_to_of_last_status_changed() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-05T00:00:00Z", "open", "closed"));
assert_eq!(log.latest_state().map(|s| s.as_str()), Some("closed"));
}
#[test]
fn lead_time_returns_none_for_empty_log() {
let fallback = IsoDate::new("2026-03-01").unwrap();
assert!(EventLog::new().lead_time(&fallback, is_terminal).is_none());
}
#[test]
fn lead_time_returns_none_when_not_closed() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
let fallback = IsoDate::new("2026-03-01").unwrap();
assert!(log.lead_time(&fallback, is_terminal).is_none());
}
#[test]
fn lead_time_returns_days_from_creation_to_close() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
let fallback = IsoDate::new("2026-03-01").unwrap();
assert_eq!(
log.lead_time(&fallback, is_terminal),
Some(Duration::from_days(8))
);
}
#[test]
fn cycle_time_returns_none_when_not_closed() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
let fallback = IsoDate::new("2026-03-01").unwrap();
assert!(log.cycle_time(&fallback, is_terminal, is_ongoing).is_none());
}
#[test]
fn cycle_time_uses_first_ongoing_to_close() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-05T00:00:00Z", "open", "in-progress"));
log.push(changed("2026-03-09T00:00:00Z", "in-progress", "closed"));
let fallback = IsoDate::new("2026-03-01").unwrap();
assert_eq!(
log.cycle_time(&fallback, is_terminal, is_ongoing),
Some(Duration::from_days(4))
);
}
#[test]
fn cycle_time_falls_back_to_creation_when_no_ongoing() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
let fallback = IsoDate::new("2026-03-01").unwrap();
assert_eq!(
log.cycle_time(&fallback, is_terminal, is_ongoing),
Some(Duration::from_days(8))
);
}
#[test]
fn flow_efficiency_pct_returns_none_when_no_active_period() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
assert!(log.flow_efficiency_pct(is_ongoing, is_stalled).is_none());
}
#[test]
fn flow_efficiency_pct_returns_none_when_no_ongoing_transition() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
assert!(log.flow_efficiency_pct(is_ongoing, is_stalled).is_none());
}
#[test]
fn flow_efficiency_pct_with_no_stall_is_full() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-03T00:00:00Z", "open", "in-progress"));
log.push(changed("2026-03-11T00:00:00Z", "in-progress", "closed"));
let pct = log.flow_efficiency_pct(is_ongoing, is_stalled).unwrap();
assert!((pct - 100.0).abs() < 0.5, "expected ~100%, got {pct:.1}%");
}
#[test]
fn flow_efficiency_pct_is_active_over_active_plus_stalled() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-03T00:00:00Z", "open", "in-progress"));
log.push(changed("2026-03-05T00:00:00Z", "in-progress", "blocked"));
log.push(changed("2026-03-09T00:00:00Z", "blocked", "in-progress"));
log.push(changed("2026-03-11T00:00:00Z", "in-progress", "closed"));
let pct = log.flow_efficiency_pct(is_ongoing, is_stalled).unwrap();
assert!((pct - 50.0).abs() < 1.0, "expected ~50%, got {pct:.1}%");
}
#[test]
fn queue_time_creation_to_first_active() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-04T00:00:00Z", "open", "in-progress"));
let fallback = IsoDate::new("2026-03-01").unwrap();
let q = log.queue_time(&fallback, is_ongoing).unwrap();
assert_eq!(q, Duration::from_days(3));
}
#[test]
fn queue_time_none_when_no_active_transition() {
let mut log = EventLog::new();
log.push(created("2026-03-01T00:00:00Z", "open"));
log.push(changed("2026-03-09T00:00:00Z", "open", "closed"));
let fallback = IsoDate::new("2026-03-01").unwrap();
assert!(log.queue_time(&fallback, is_ongoing).is_none());
}
#[test]
fn _is_terminal_helper_still_callable() {
assert!(is_terminal("closed"));
}
proptest! {
#[test]
fn prop_event_clone_equals_original(e in strategy::event()) {
prop_assert_eq!(e.clone(), e);
}
#[test]
fn prop_event_log_clone_equals_original(log in strategy::event_log()) {
prop_assert_eq!(log.clone(), log);
}
}
}