use super::*;
use crate::{explain_work, status, status_at_horizon};
use chrono::{DateTime, TimeZone, Utc};
fn at() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0)
.single()
.expect("fixed time")
}
fn fixture(name: &str) -> Plan {
let text = match name {
"conditional" => include_str!("../../../../../tests/support/conditional-plan.json"),
_ => include_str!("../../../../../tests/support/execution-plan.json"),
};
serde_json::from_str(text).expect("fixture")
}
const PATH_VARIANCE: f64 = 524.0 / 9.0;
#[test]
fn forecast_reports_the_mean_critical_path_and_withholds_probabilities_without_a_horizon() {
let plan = fixture("execution");
let summary = status(&plan, true, at()).expect("status");
let forecast = summary.forecast.expect("forecast beside the percentiles");
assert_eq!(forecast.horizon_hours, None);
assert_eq!(forecast.probability_by_horizon, None);
let pert = &forecast.pert_approximation;
assert_eq!(pert.probability_by_horizon, None);
let keys: Vec<&str> = pert.path.iter().map(|step| step.key.0.as_str()).collect();
assert_eq!(
keys,
["TEST-A", "TEST-B", "TEST-C", "TEST-E", "TEST-F", "TEST-M1"]
);
assert!((pert.mean_hours - summary.expected_finish_hours).abs() < 1e-9);
assert!((pert.mean_hours - 76.0).abs() < 1e-9);
assert!((pert.variance_hours2 - PATH_VARIANCE).abs() < 1e-9);
assert!((forecast.spread.pert_sigma_hours - PATH_VARIANCE.sqrt()).abs() < 1e-9);
assert!(forecast.spread.sampled_std_dev_hours > 0.0);
assert!(pert.note.contains("approximation") && pert.note.contains("overstates"));
let [milestone] = forecast.milestones.as_slice() else {
panic!("one milestone: {:?}", forecast.milestones);
};
assert_eq!(milestone.key.0, "TEST-M1");
assert_eq!(milestone.probability_by_horizon, None);
assert!(milestone.p50_finish_hours <= milestone.p95_finish_hours);
}
#[test]
fn a_horizon_adds_sampled_and_approximate_probabilities() {
let plan = fixture("execution");
let summary = status_at_horizon(&plan, true, at(), Some(80.0)).expect("status");
let forecast = summary.forecast.expect("forecast");
assert_eq!(forecast.horizon_hours, Some(80.0));
let sampled = forecast.probability_by_horizon.expect("sampled");
assert!(sampled > 0.0 && sampled < 1.0);
let z = (80.0 - 76.0) / PATH_VARIANCE.sqrt();
let expected = 0.5 * (1.0 + libm::erf(z / std::f64::consts::SQRT_2));
let approximate = forecast
.pert_approximation
.probability_by_horizon
.expect("approximate");
assert!((approximate - expected).abs() < 1e-12);
assert_eq!(forecast.milestones[0].probability_by_horizon, Some(sampled));
let plain = status(&plan, true, at()).expect("status");
assert_eq!(summary.p80_finish_hours, plain.p80_finish_hours);
assert_eq!(summary.expected_finish_hours, plain.expected_finish_hours);
}
#[test]
fn forecast_is_withheld_with_the_percentiles() {
let plan = fixture("execution");
let deterministic = status_at_horizon(&plan, false, at(), Some(80.0)).expect("status");
assert!(deterministic.p50_finish_hours.is_none() && deterministic.forecast.is_none());
let conditional = fixture("conditional");
let open = status_at_horizon(&conditional, true, at(), Some(80.0)).expect("status");
assert!(open.open_choices.is_some());
assert!(open.p50_finish_hours.is_none() && open.forecast.is_none());
}
#[test]
fn a_horizon_must_be_finite_and_not_negative() {
let plan = fixture("execution");
for horizon in [-1.0, f64::NAN, f64::INFINITY] {
let refused = status_at_horizon(&plan, true, at(), Some(horizon));
assert!(
matches!(refused, Err(EngineError::InvalidCommand { .. })),
"{horizon}: {refused:?}"
);
}
assert!(status_at_horizon(&plan, true, at(), Some(0.0)).is_ok());
}
#[test]
fn explain_reports_drag_and_classical_variance() {
let plan = fixture("execution");
let id = |key: &str| plan.find_work_by_key(key).expect("work").id;
let first = explain_work(&plan, id("TEST-A"), at()).expect("explain");
assert!((first.drag_hours.expect("drag") - 4.0).abs() < 1e-9);
assert!((first.pert_variance_hours2.expect("variance") - 4.0 / 9.0).abs() < 1e-12);
let parallel = explain_work(&plan, id("TEST-D"), at()).expect("explain");
assert_eq!(parallel.drag_hours, Some(0.0));
assert!((parallel.pert_variance_hours2.expect("variance") - 1024.0 / 36.0).abs() < 1e-12);
}