1use crate::estimator::EffortEstimator;
38pub use crate::plan::EarningRule;
39use crate::plan::{
40 Deliverable, DeliverableStatus, FINISH_ID, PlanGraph, PlanId, PlannerError, START_ID,
41};
42use crate::schedule::{compute_cpm, effort_basis};
43use crate::task::CriticalPathResult;
44use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
45use serde::{Deserialize, Serialize};
46use std::collections::HashMap;
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct Calendar {
52 #[serde(default = "default_hours_per_day")]
55 pub hours_per_day: f32,
56 #[serde(default = "default_workdays")]
58 pub workdays: Vec<String>,
59 #[serde(default)]
61 pub utc_offset_minutes: i32,
62}
63
64fn default_hours_per_day() -> f32 {
65 8.0
66}
67
68fn default_workdays() -> Vec<String> {
69 ["mon", "tue", "wed", "thu", "fri"]
70 .map(String::from)
71 .to_vec()
72}
73
74impl Default for Calendar {
75 fn default() -> Self {
76 Self {
77 hours_per_day: default_hours_per_day(),
78 workdays: default_workdays(),
79 utc_offset_minutes: 0,
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct BaselineRow {
87 pub id: String,
88 pub es: f32,
90 pub ef: f32,
92 pub budget: f32,
94 #[serde(default = "one")]
96 pub cost_rate: f32,
97}
98
99fn one() -> f32 {
100 1.0
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct Baseline {
106 pub number: u32,
107 pub start: DateTime<Utc>,
108 pub calendar: Option<Calendar>,
110 pub rows: Vec<BaselineRow>,
111 pub bac: f32,
112 pub finish_hours: f32,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct Actuals {
119 pub earned_pct: Option<u8>,
120 pub actual_hours: Option<f32>,
121 pub leased_hours: f32,
122 pub evidence: Vec<String>,
123 #[serde(default)]
126 pub removed_at: Option<DateTime<Utc>>,
127 #[serde(default)]
131 pub frozen_pct: Option<u8>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(tag = "status", rename_all = "snake_case")]
140pub enum EvRowStatus {
141 Pending,
142 Ready,
143 InProgress,
144 Complete,
145 Failed {
146 reason: String,
147 },
148 Removed,
151}
152
153impl From<DeliverableStatus> for EvRowStatus {
154 fn from(s: DeliverableStatus) -> Self {
155 match s {
156 DeliverableStatus::Pending => Self::Pending,
157 DeliverableStatus::Ready => Self::Ready,
158 DeliverableStatus::InProgress => Self::InProgress,
159 DeliverableStatus::Complete => Self::Complete,
160 DeliverableStatus::Failed { reason } => Self::Failed { reason },
161 }
162 }
163}
164
165impl PartialEq<DeliverableStatus> for EvRowStatus {
166 fn eq(&self, other: &DeliverableStatus) -> bool {
167 match (self, other) {
168 (Self::Pending, DeliverableStatus::Pending)
169 | (Self::Ready, DeliverableStatus::Ready)
170 | (Self::InProgress, DeliverableStatus::InProgress)
171 | (Self::Complete, DeliverableStatus::Complete) => true,
172 (Self::Failed { reason: a }, DeliverableStatus::Failed { reason: b }) => a == b,
173 _ => false,
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct Undefined {
181 pub field: String,
182 pub reason: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct EvRow {
188 pub id: String,
189 pub budget: f32,
190 pub pv: f32,
191 pub earned_pct: f32,
192 pub ev: f32,
193 pub ac: f32,
195 pub status: EvRowStatus,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct EvSummary {
202 pub baseline_number: u32,
205 pub spi: Option<f32>,
206 pub cpi: Option<f32>,
207 #[serde(default)]
210 pub backfilled: bool,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct EvReport {
216 pub as_of: DateTime<Utc>,
219 pub baseline_number: u32,
220 pub bac: f32,
221 pub pv: f32,
222 pub ev: f32,
223 pub ac: f32,
224 pub sv: f32,
225 pub cv: f32,
226 pub spi: Option<f32>,
227 pub cpi: Option<f32>,
228 pub eac: Option<f32>,
229 pub etc: Option<f32>,
230 pub vac: Option<f32>,
231 pub tcpi: Option<f32>,
232 pub undefined: Vec<Undefined>,
233 pub rows: Vec<EvRow>,
234 pub critical_float_consumed_hours: f32,
235 pub alerts: Vec<String>,
236 pub excluded_unbaselined: Vec<String>,
241}
242
243fn weekday_from_name(name: &str) -> Option<Weekday> {
244 Some(match name {
245 "mon" => Weekday::Mon,
246 "tue" => Weekday::Tue,
247 "wed" => Weekday::Wed,
248 "thu" => Weekday::Thu,
249 "fri" => Weekday::Fri,
250 "sat" => Weekday::Sat,
251 "sun" => Weekday::Sun,
252 _ => return None,
253 })
254}
255
256fn workdays_per_week(days: &[Weekday]) -> usize {
257 let mut uniq = days.to_vec();
258 uniq.sort_by_key(|d| d.num_days_from_monday());
259 uniq.dedup();
260 uniq.len()
261}
262
263fn cost_rate(d: &Deliverable) -> Result<f32, PlannerError> {
265 match d.metadata.get("cost_rate") {
266 None | Some(serde_json::Value::Null) => Ok(1.0),
267 Some(v) => match v.as_f64() {
268 Some(x) if x.is_finite() && x >= 0.0 && x <= f64::from(f32::MAX) => Ok(x as f32),
269 _ => Err(PlannerError::InvalidGraph {
270 reason: format!(
271 "deliverable {:?}: metadata.cost_rate must be a finite number >= 0, got {v}",
272 d.id
273 ),
274 }),
275 },
276 }
277}
278
279fn budget_of(d: &Deliverable, estimator: &EffortEstimator) -> Result<f32, PlannerError> {
280 let budget = effort_basis(d, estimator) * cost_rate(d)?;
281 if budget.is_finite() {
282 Ok(budget)
283 } else {
284 Err(PlannerError::InvalidGraph {
285 reason: format!("deliverable {:?}: budget is not finite", d.id),
286 })
287 }
288}
289
290fn finish_of(cpm: &CriticalPathResult) -> f32 {
292 cpm.tasks
293 .iter()
294 .find(|t| t.id == FINISH_ID)
295 .map(|t| t.earliest_finish)
296 .unwrap_or_else(|| {
297 cpm.tasks
298 .iter()
299 .map(|t| t.earliest_finish)
300 .fold(0.0, f32::max)
301 })
302}
303
304pub fn frozen_pct(rule: EarningRule, status: &DeliverableStatus, reported: Option<u8>) -> u8 {
308 earned_pct(rule, status, reported.map(|p| p.min(100))).clamp(0.0, 100.0) as u8
309}
310
311fn earned_pct(rule: EarningRule, status: &DeliverableStatus, reported: Option<u8>) -> f64 {
312 if *status == DeliverableStatus::Complete {
313 return 100.0;
314 }
315 match rule {
316 EarningRule::ZeroHundred => 0.0,
317 EarningRule::FiftyFifty => {
318 if *status == DeliverableStatus::InProgress || reported.is_some() {
319 50.0
320 } else {
321 0.0
322 }
323 }
324 EarningRule::Weighted => f64::from(reported.unwrap_or(0)),
325 }
326}
327
328impl Calendar {
329 pub fn validate(&self) -> Result<(), PlannerError> {
331 let bad = |reason: String| Err(PlannerError::InvalidGraph { reason });
332 if !(self.hours_per_day.is_finite()
333 && self.hours_per_day > 0.0
334 && self.hours_per_day <= 24.0)
335 {
336 return bad(format!(
337 "calendar.hours_per_day must be in (0, 24], got {}",
338 self.hours_per_day
339 ));
340 }
341 if !(-1440..=1440).contains(&self.utc_offset_minutes) {
342 return bad(format!(
343 "calendar.utc_offset_minutes must be in [-1440, 1440], got {}",
344 self.utc_offset_minutes
345 ));
346 }
347 if self.workdays.is_empty() {
348 return bad("calendar.workdays must not be empty".to_string());
349 }
350 for name in &self.workdays {
351 if weekday_from_name(name).is_none() {
352 return bad(format!(
353 "calendar.workdays entry {name:?} is not a lowercase 3-letter weekday (mon..sun)"
354 ));
355 }
356 }
357 Ok(())
358 }
359}
360
361pub fn elapsed_hours(
363 start: DateTime<Utc>,
364 as_of: DateTime<Utc>,
365 calendar: Option<&Calendar>,
366) -> Result<f32, PlannerError> {
367 if as_of <= start {
368 return Ok(0.0);
369 }
370 let Some(calendar) = calendar else {
371 return Ok((as_of - start).num_milliseconds() as f32 / 3_600_000.0);
372 };
373 calendar.validate()?;
374 let off = Duration::minutes(i64::from(calendar.utc_offset_minutes));
375 let (ls, le) = (start.naive_utc() + off, as_of.naive_utc() + off);
376 let workdays: Vec<Weekday> = calendar
377 .workdays
378 .iter()
379 .filter_map(|n| weekday_from_name(n))
380 .collect();
381 let hpd = f64::from(calendar.hours_per_day);
382 let is_work = |d: NaiveDate| workdays.contains(&d.weekday());
383 let partial = |d: NaiveDate, from: chrono::NaiveDateTime, to: chrono::NaiveDateTime| -> f64 {
385 if !is_work(d) {
386 return 0.0;
387 }
388 let day0 = d.and_hms_opt(0, 0, 0).unwrap_or(from);
389 let lo = (from - day0).num_milliseconds() as f64 / 3_600_000.0;
390 let hi = (to - day0).num_milliseconds() as f64 / 3_600_000.0;
391 (hi.min(hpd) - lo.max(0.0)).max(0.0)
392 };
393 let (sd, ed) = (ls.date(), le.date());
394 let total = if sd == ed {
395 partial(sd, ls, le)
396 } else {
397 let mut t = partial(sd, ls, le) + partial(ed, ls, le);
398 let interior = (ed - sd).num_days() - 1;
400 let weeks = interior / 7;
401 let per_week = workdays_per_week(&workdays) as f64;
402 t += weeks as f64 * per_week * hpd;
403 let mut d = sd + Duration::days(1 + weeks * 7);
404 while d < ed {
405 if is_work(d) {
406 t += hpd;
407 }
408 d += Duration::days(1);
409 }
410 t
411 };
412 Ok(total as f32)
413}
414
415pub fn build_baseline(
417 graph: &PlanGraph,
418 cpm: &CriticalPathResult,
419 start: DateTime<Utc>,
420 calendar: Option<Calendar>,
421 number: u32,
422) -> Result<Baseline, PlannerError> {
423 if let Some(c) = &calendar {
424 c.validate()?;
425 }
426 let by_id: HashMap<&str, &crate::task::Task> =
427 cpm.tasks.iter().map(|t| (t.id.as_str(), t)).collect();
428 let estimator = EffortEstimator::new();
429 let mut rows = Vec::new();
430 for d in &graph.deliverables {
431 if d.id == START_ID || d.id == FINISH_ID {
432 continue;
433 }
434 let Some(t) = by_id.get(d.id.as_str()) else {
435 return Err(PlannerError::InvalidGraph {
436 reason: format!("deliverable {:?} missing from CPM result", d.id),
437 });
438 };
439 rows.push(BaselineRow {
440 id: d.id.clone(),
441 es: t.earliest_start,
442 ef: t.earliest_finish,
443 budget: budget_of(d, &estimator)?,
444 cost_rate: cost_rate(d)?,
445 });
446 }
447 let bac = rows.iter().map(|r| f64::from(r.budget)).sum::<f64>() as f32;
448 if !bac.is_finite() {
449 return Err(PlannerError::InvalidGraph {
450 reason: "budget at completion (sum of effort x cost_rate) exceeds the f32 range"
451 .to_string(),
452 });
453 }
454 Ok(Baseline {
455 number,
456 start,
457 calendar,
458 rows,
459 bac,
460 finish_hours: finish_of(cpm),
461 })
462}
463
464pub fn compute_ev(
487 baseline: &Baseline,
488 graph: &PlanGraph,
489 statuses: &HashMap<String, DeliverableStatus>,
490 rules: &HashMap<String, EarningRule>,
491 actuals: &HashMap<String, Actuals>,
492 as_of: DateTime<Utc>,
493 previous_snapshots: &[EvSummary],
494) -> Result<EvReport, PlannerError> {
495 let t = f64::from(elapsed_hours(
496 baseline.start,
497 as_of,
498 baseline.calendar.as_ref(),
499 )?);
500 let (mut pv, mut ev, mut ac) = (0.0_f64, 0.0_f64, 0.0_f64);
501 let mut rows = Vec::with_capacity(baseline.rows.len());
502 let live: std::collections::HashSet<&str> =
503 graph.deliverables.iter().map(|d| d.id.as_str()).collect();
504 let valid = |h: f32| h.is_finite() && h >= 0.0;
505 let hours_of = |a: &Actuals| -> f32 {
507 let h = match a.actual_hours {
508 Some(h) if valid(h) => h,
509 _ => a.leased_hours,
510 };
511 if valid(h) { h } else { 0.0 }
512 };
513 for r in &baseline.rows {
514 let budget = f64::from(r.budget);
515 let (es, ef) = (f64::from(r.es), f64::from(r.ef));
516 let row_pv = if ef <= es {
517 if t >= es { budget } else { 0.0 }
518 } else {
519 budget * ((t - es) / (ef - es)).clamp(0.0, 1.0)
520 };
521 let act = actuals.get(&r.id);
522 let (status, pct) = if live.contains(r.id.as_str()) {
523 let status = statuses
524 .get(&r.id)
525 .cloned()
526 .unwrap_or(DeliverableStatus::Pending);
527 let rule = rules.get(&r.id).copied().unwrap_or_default();
528 let reported = act.and_then(|a| a.earned_pct).map(|p| p.min(100));
529 let pct = earned_pct(rule, &status, reported);
530 (EvRowStatus::from(status), pct)
531 } else {
532 let frozen = act.and_then(|a| a.frozen_pct).map_or(0, |p| p.min(100));
533 (EvRowStatus::Removed, f64::from(frozen))
534 };
535 let hours = act.map_or(0.0, hours_of);
536 let row_ev = budget * pct / 100.0;
537 let row_ac = f64::from(r.cost_rate) * f64::from(hours);
538 pv += row_pv;
539 ev += row_ev;
540 rows.push(EvRow {
541 id: r.id.clone(),
542 budget: r.budget,
543 pv: row_pv as f32,
544 earned_pct: pct as f32,
545 ev: row_ev as f32,
546 ac: row_ac as f32,
547 status,
548 });
549 }
550 let rates: HashMap<&str, f32> = baseline
553 .rows
554 .iter()
555 .map(|r| (r.id.as_str(), r.cost_rate))
556 .collect();
557 let mut spend: Vec<(&String, &Actuals)> = actuals.iter().collect();
558 spend.sort_by(|a, b| a.0.cmp(b.0));
559 for (id, a) in spend {
560 let rate = rates.get(id.as_str()).copied().unwrap_or(1.0);
563 ac += f64::from(rate) * f64::from(hours_of(a));
564 }
565 let bac: f64 = baseline.rows.iter().map(|r| f64::from(r.budget)).sum();
566 let mut undefined = Vec::new();
567 let mut ratio = |field: &str, v: Option<f64>, reason: &str| -> Option<f64> {
569 let out = v.filter(|x| (*x as f32).is_finite());
570 if out.is_none() {
571 let reason = if v.is_some() {
572 "result is not finite as f32"
573 } else {
574 reason
575 };
576 undefined.push(Undefined {
577 field: field.to_string(),
578 reason: reason.to_string(),
579 });
580 }
581 out
582 };
583 let spi = ratio(
584 "spi",
585 (pv != 0.0).then(|| ev / pv),
586 "PV is 0 before any work was planned",
587 );
588 let cpi = ratio(
589 "cpi",
590 (ac != 0.0).then(|| ev / ac),
591 "AC is 0, no cost recorded yet",
592 );
593 let eac_reason = if cpi.is_none() {
594 "CPI is undefined"
595 } else {
596 "CPI is 0, nothing earned for the cost spent"
597 };
598 let eac = ratio(
599 "eac",
600 cpi.filter(|c| *c != 0.0).map(|c| bac / c),
601 eac_reason,
602 );
603 let etc = ratio("etc", eac.map(|e| e - ac), "EAC is undefined");
604 let vac = ratio("vac", eac.map(|e| bac - e), "EAC is undefined");
605 let tcpi = ratio(
606 "tcpi",
607 ((bac - ac).abs() > 1e-9 * bac.max(1.0)).then(|| (bac - ev) / (bac - ac)),
608 "BAC - AC is 0, no budget remains",
609 );
610 let out = |v: Option<f64>| v.map(|x| x as f32);
611
612 let cpm = compute_cpm(graph)?;
613 let critical_float = (finish_of(&cpm) - baseline.finish_hours).max(0.0);
614
615 let alerts = trend_alerts(previous_snapshots, baseline.number);
616 for (field, v) in [
617 ("pv", pv),
618 ("ev", ev),
619 ("ac", ac),
620 ("sv", ev - pv),
621 ("cv", ev - ac),
622 ] {
623 if !(v as f32).is_finite() {
624 return Err(PlannerError::InvalidGraph {
625 reason: format!(
626 "earned-value total {field} exceeds the f32 range (check cost_rate and \
627 reported hours)"
628 ),
629 });
630 }
631 }
632
633 let report = EvReport {
634 as_of,
635 baseline_number: baseline.number,
636 bac: baseline.bac,
637 pv: pv as f32,
638 ev: ev as f32,
639 ac: ac as f32,
640 sv: (ev - pv) as f32,
641 cv: (ev - ac) as f32,
642 spi: out(spi),
643 cpi: out(cpi),
644 eac: out(eac),
645 etc: out(etc),
646 vac: out(vac),
647 tcpi: out(tcpi),
648 undefined,
649 rows,
650 critical_float_consumed_hours: critical_float,
651 alerts,
652 excluded_unbaselined: {
653 let known: std::collections::HashSet<&str> =
654 baseline.rows.iter().map(|r| r.id.as_str()).collect();
655 let mut v: Vec<String> = graph
656 .deliverables
657 .iter()
658 .filter(|d| d.id != START_ID && d.id != FINISH_ID && !known.contains(d.id.as_str()))
659 .map(|d| d.id.clone())
660 .collect();
661 v.sort();
662 v
663 },
664 };
665 Ok(report)
666}
667
668pub const MAX_BASELINE_REASON_CHARS: usize = crate::plan::MAX_EVIDENCE_CHARS;
671
672pub const SNAPSHOT_EXPORT_LIMIT: usize = 100;
674
675#[derive(Debug, Clone, PartialEq)]
677pub struct BaselineRequest {
678 pub plan_id: PlanId,
679 pub start: Option<DateTime<Utc>>,
681 pub calendar: Option<Calendar>,
683 pub reason: Option<String>,
686}
687
688impl BaselineRequest {
689 pub fn new(plan_id: PlanId) -> Self {
690 Self {
691 plan_id,
692 start: None,
693 calendar: None,
694 reason: None,
695 }
696 }
697
698 pub fn with_start(mut self, start: DateTime<Utc>) -> Self {
699 self.start = Some(start);
700 self
701 }
702
703 pub fn with_calendar(mut self, calendar: Calendar) -> Self {
704 self.calendar = Some(calendar);
705 self
706 }
707
708 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
709 self.reason = Some(reason.into());
710 self
711 }
712}
713
714#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
716pub struct BaselineOutcome {
717 pub plan_id: String,
718 pub baseline_number: u32,
719 pub start: DateTime<Utc>,
720 pub calendar: Option<Calendar>,
721 pub bac: f32,
722 pub finish_hours: f32,
723 pub deliverable_count: usize,
724 pub reason: Option<String>,
725}
726
727#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
729#[serde(rename_all = "snake_case")]
730pub enum SnapshotFormat {
731 #[default]
733 Json,
734 Markdown,
736}
737
738#[derive(Debug, Clone, PartialEq)]
740pub struct SnapshotRequest {
741 pub plan_id: PlanId,
742 pub as_of: Option<DateTime<Utc>>,
747 pub format: SnapshotFormat,
748}
749
750impl SnapshotRequest {
751 pub fn new(plan_id: PlanId) -> Self {
752 Self {
753 plan_id,
754 as_of: None,
755 format: SnapshotFormat::default(),
756 }
757 }
758
759 pub fn with_as_of(mut self, as_of: DateTime<Utc>) -> Self {
760 self.as_of = Some(as_of);
761 self
762 }
763
764 pub fn with_format(mut self, format: SnapshotFormat) -> Self {
765 self.format = format;
766 self
767 }
768}
769
770#[derive(Debug, Clone, PartialEq, Serialize)]
772pub struct SnapshotOutcome {
773 pub summary: SnapshotSummary,
774 pub format: SnapshotFormat,
775 pub export: serde_json::Value,
781 pub snapshot_count: usize,
783}
784
785pub fn trend_alerts(snapshots: &[EvSummary], baseline_number: u32) -> Vec<String> {
793 let mut alerts = Vec::new();
794 let current: Vec<&EvSummary> = snapshots
795 .iter()
796 .filter(|s| s.baseline_number == baseline_number && !s.backfilled)
797 .collect();
798 if let [.., a, b] = current.as_slice() {
799 let low = |v: Option<f32>| v.is_some_and(|x| x < 0.9);
800 if low(a.spi) && low(b.spi) {
801 alerts.push("SPI_BELOW_0_9".to_string());
802 }
803 if low(a.cpi) && low(b.cpi) {
804 alerts.push("CPI_BELOW_0_9".to_string());
805 }
806 }
807 alerts
808}
809
810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
814pub struct SnapshotSummary {
815 pub taken_at: DateTime<Utc>,
816 pub as_of: DateTime<Utc>,
817 pub baseline_number: u32,
818 pub bac: f32,
819 pub pv: f32,
820 pub ev: f32,
821 pub ac: f32,
822 pub sv: f32,
823 pub cv: f32,
824 pub spi: Option<f32>,
825 pub cpi: Option<f32>,
826 pub eac: Option<f32>,
827 pub etc: Option<f32>,
828 pub vac: Option<f32>,
829 pub tcpi: Option<f32>,
830 #[serde(default)]
833 pub undefined: Vec<Undefined>,
834 pub alerts: Vec<String>,
835 #[serde(default)]
840 pub backfilled: bool,
841}
842
843pub const BACKFILL_TOLERANCE_HOURS: i64 = 1;
846
847pub fn is_backfilled(as_of: DateTime<Utc>, taken_at: DateTime<Utc>) -> bool {
849 as_of < taken_at - Duration::hours(BACKFILL_TOLERANCE_HOURS)
850}
851
852impl SnapshotSummary {
853 pub fn from_report(report: &EvReport, taken_at: DateTime<Utc>) -> Self {
855 Self {
856 taken_at,
857 as_of: report.as_of,
858 baseline_number: report.baseline_number,
859 bac: report.bac,
860 pv: report.pv,
861 ev: report.ev,
862 ac: report.ac,
863 sv: report.sv,
864 cv: report.cv,
865 spi: report.spi,
866 cpi: report.cpi,
867 eac: report.eac,
868 etc: report.etc,
869 vac: report.vac,
870 tcpi: report.tcpi,
871 undefined: report.undefined.clone(),
872 alerts: report.alerts.clone(),
873 backfilled: is_backfilled(report.as_of, taken_at),
874 }
875 }
876
877 pub fn ratios(&self) -> EvSummary {
879 EvSummary {
880 baseline_number: self.baseline_number,
881 spi: self.spi,
882 cpi: self.cpi,
883 backfilled: self.backfilled,
884 }
885 }
886}
887
888pub fn render_snapshots_markdown(snapshots: &[SnapshotSummary]) -> String {
892 let ratio = |v: Option<f32>| v.map_or_else(|| "n/a".to_string(), |x| format!("{x:.2}"));
893 let mut out = String::from(
894 "| date | PV | EV | AC | SPI | CPI | EAC |\n|---|---:|---:|---:|---:|---:|---:|\n",
895 );
896 for s in snapshots {
897 out.push_str(&format!(
898 "| {} | {:.2} | {:.2} | {:.2} | {} | {} | {} |\n",
899 s.as_of.format("%Y-%m-%dT%H:%MZ"),
900 s.pv,
901 s.ev,
902 s.ac,
903 ratio(s.spi),
904 ratio(s.cpi),
905 ratio(s.eac),
906 ));
907 }
908 out
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914 use chrono::{TimeZone, Timelike};
915
916 const EPS: f32 = 1e-3;
917
918 fn at(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
919 Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap()
920 }
921
922 fn near(a: f32, b: f32) -> bool {
923 (a - b).abs() < EPS
924 }
925
926 fn deliverable(id: &str, effort: f32, prereqs: &[&str]) -> Deliverable {
927 Deliverable {
928 id: id.to_string(),
929 owned_files: vec![],
930 prerequisites: prereqs
931 .iter()
932 .map(|p| crate::plan::Prerequisite::Id((*p).to_string()))
933 .collect(),
934 estimated_effort_hours: Some(effort),
935 duration_hours: None,
936 estimate: None,
937 metadata: serde_json::Value::Null,
938 milestone: false,
939 earning_rule: None,
940 }
941 }
942
943 fn graph(ds: Vec<Deliverable>) -> PlanGraph {
944 PlanGraph {
945 deliverables: ds,
946 max_chained_dispatch: None,
947 }
948 }
949
950 fn row(id: &str, es: f32, ef: f32, budget: f32) -> BaselineRow {
951 BaselineRow {
952 id: id.to_string(),
953 es,
954 ef,
955 budget,
956 cost_rate: 1.0,
957 }
958 }
959
960 fn baseline(rows: Vec<BaselineRow>) -> Baseline {
961 let bac = rows.iter().map(|r| r.budget).sum();
962 let finish_hours = rows.iter().map(|r| r.ef).fold(0.0, f32::max);
963 Baseline {
964 number: 1,
965 start: at(2026, 1, 5, 0),
966 calendar: None,
967 rows,
968 bac,
969 finish_hours,
970 }
971 }
972
973 fn statuses(pairs: &[(&str, DeliverableStatus)]) -> HashMap<String, DeliverableStatus> {
974 pairs
975 .iter()
976 .map(|(k, v)| ((*k).to_string(), v.clone()))
977 .collect()
978 }
979
980 fn report(
981 b: &Baseline,
982 g: &PlanGraph,
983 st: &HashMap<String, DeliverableStatus>,
984 rules: &HashMap<String, EarningRule>,
985 actuals: &HashMap<String, Actuals>,
986 hours: i64,
987 ) -> EvReport {
988 let as_of = b.start + Duration::hours(hours);
989 compute_ev(b, g, st, rules, actuals, as_of, &[]).unwrap()
990 }
991
992 fn one(rule: EarningRule, status: DeliverableStatus, pct: Option<u8>) -> EvReport {
993 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
994 let g = graph(vec![deliverable("a", 100.0, &[])]);
995 let st = statuses(&[("a", status)]);
996 let rules = HashMap::from([("a".to_string(), rule)]);
997 let actuals = HashMap::from([(
998 "a".to_string(),
999 Actuals {
1000 earned_pct: pct,
1001 ..Actuals::default()
1002 },
1003 )]);
1004 report(&b, &g, &st, &rules, &actuals, 5)
1005 }
1006
1007 fn textbook() -> EvReport {
1009 let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 10.0, 20.0, 50.0)]);
1010 let g = graph(vec![
1011 deliverable("a", 50.0, &[]),
1012 deliverable("b", 50.0, &["a"]),
1013 ]);
1014 let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1015 let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1016 let actuals = HashMap::from([(
1017 "a".to_string(),
1018 Actuals {
1019 earned_pct: Some(80),
1020 actual_hours: Some(48.0),
1021 ..Actuals::default()
1022 },
1023 )]);
1024 report(&b, &g, &st, &rules, &actuals, 10)
1025 }
1026
1027 #[test]
1028 fn wall_clock_elapsed_is_plain_hours() {
1029 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 6, 6), None).unwrap();
1030 assert!(near(h, 30.0));
1031 }
1032
1033 #[test]
1034 fn elapsed_before_start_is_zero() {
1035 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 4, 0), None).unwrap();
1036 assert!(near(h, 0.0));
1037 }
1038
1039 #[test]
1040 fn calendar_skips_weekends() {
1041 let c = Calendar::default();
1043 let h = elapsed_hours(at(2026, 1, 9, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1044 assert!(near(h, 8.0));
1045 }
1046
1047 #[test]
1048 fn calendar_counts_partial_day() {
1049 let c = Calendar::default();
1051 let start = at(2026, 1, 5, 3);
1052 let h = elapsed_hours(start, start + Duration::minutes(150), Some(&c)).unwrap();
1053 assert!(near(h, 2.5));
1054 }
1055
1056 #[test]
1057 fn calendar_ignores_hours_after_the_working_window() {
1058 let c = Calendar::default();
1059 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 5, 20), Some(&c)).unwrap();
1060 assert!(near(h, 8.0));
1061 }
1062
1063 #[test]
1064 fn calendar_over_many_weeks_counts_whole_weeks() {
1065 let c = Calendar::default();
1067 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 3, 16, 0), Some(&c)).unwrap();
1068 assert!(near(h, 400.0));
1069 }
1070
1071 #[test]
1072 fn calendar_utc_offset_shifts_the_local_day() {
1073 let c = Calendar {
1075 utc_offset_minutes: 120,
1076 ..Calendar::default()
1077 };
1078 let h = elapsed_hours(at(2026, 1, 5, 22), at(2026, 1, 6, 0), Some(&c)).unwrap();
1079 assert!(near(h, 2.0));
1080 }
1081
1082 #[test]
1083 fn calendar_custom_workdays_count_only_listed_days() {
1084 let c = Calendar {
1085 workdays: vec!["sat".into()],
1086 ..Calendar::default()
1087 };
1088 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1089 assert!(near(h, 8.0));
1090 }
1091
1092 fn invalid(c: Calendar) -> bool {
1093 matches!(c.validate(), Err(PlannerError::InvalidGraph { .. }))
1094 }
1095
1096 #[test]
1097 fn calendar_rejects_zero_hours_per_day() {
1098 assert!(invalid(Calendar {
1099 hours_per_day: 0.0,
1100 ..Calendar::default()
1101 }));
1102 }
1103
1104 #[test]
1105 fn calendar_rejects_more_than_24_hours_per_day() {
1106 assert!(invalid(Calendar {
1107 hours_per_day: 24.5,
1108 ..Calendar::default()
1109 }));
1110 }
1111
1112 #[test]
1113 fn calendar_rejects_nan_hours_per_day() {
1114 assert!(invalid(Calendar {
1115 hours_per_day: f32::NAN,
1116 ..Calendar::default()
1117 }));
1118 }
1119
1120 #[test]
1121 fn calendar_rejects_out_of_range_offset() {
1122 assert!(invalid(Calendar {
1123 utc_offset_minutes: 1441,
1124 ..Calendar::default()
1125 }));
1126 }
1127
1128 #[test]
1129 fn calendar_rejects_empty_workdays() {
1130 assert!(invalid(Calendar {
1131 workdays: vec![],
1132 ..Calendar::default()
1133 }));
1134 }
1135
1136 #[test]
1137 fn calendar_rejects_unknown_weekday_name() {
1138 assert!(invalid(Calendar {
1139 workdays: vec!["monday".into()],
1140 ..Calendar::default()
1141 }));
1142 }
1143
1144 #[test]
1145 fn calendar_accepts_a_24_hour_day() {
1146 assert!(
1147 Calendar {
1148 hours_per_day: 24.0,
1149 ..Calendar::default()
1150 }
1151 .validate()
1152 .is_ok()
1153 );
1154 }
1155
1156 #[test]
1157 fn pv_interpolates_linearly_inside_a_task() {
1158 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1159 let g = graph(vec![deliverable("a", 100.0, &[])]);
1160 let r = report(
1161 &b,
1162 &g,
1163 &HashMap::new(),
1164 &HashMap::new(),
1165 &HashMap::new(),
1166 15,
1167 );
1168 assert!(near(r.pv, 50.0));
1169 }
1170
1171 #[test]
1172 fn pv_is_zero_before_task_start() {
1173 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1174 let g = graph(vec![deliverable("a", 100.0, &[])]);
1175 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1176 assert!(near(r.pv, 0.0));
1177 }
1178
1179 #[test]
1180 fn pv_caps_at_budget_after_task_finish() {
1181 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1182 let g = graph(vec![deliverable("a", 100.0, &[])]);
1183 let r = report(
1184 &b,
1185 &g,
1186 &HashMap::new(),
1187 &HashMap::new(),
1188 &HashMap::new(),
1189 99,
1190 );
1191 assert!(near(r.pv, 100.0));
1192 }
1193
1194 #[test]
1195 fn pv_of_zero_length_task_is_zero_before_its_start() {
1196 let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1197 let g = graph(vec![deliverable("m", 5.0, &[])]);
1198 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 9);
1199 assert!(near(r.pv, 0.0));
1200 }
1201
1202 #[test]
1203 fn pv_of_zero_length_task_steps_to_budget_at_its_start() {
1204 let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1205 let g = graph(vec![deliverable("m", 5.0, &[])]);
1206 let r = report(
1207 &b,
1208 &g,
1209 &HashMap::new(),
1210 &HashMap::new(),
1211 &HashMap::new(),
1212 10,
1213 );
1214 assert!(near(r.pv, 5.0));
1215 }
1216
1217 #[test]
1218 fn zero_hundred_earns_nothing_while_in_progress() {
1219 let r = one(
1220 EarningRule::ZeroHundred,
1221 DeliverableStatus::InProgress,
1222 Some(90),
1223 );
1224 assert!(near(r.ev, 0.0));
1225 }
1226
1227 #[test]
1228 fn zero_hundred_earns_full_budget_when_complete() {
1229 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1230 assert!(near(r.ev, 100.0));
1231 }
1232
1233 #[test]
1234 fn default_rule_is_zero_hundred_when_unmapped() {
1235 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1236 let g = graph(vec![deliverable("a", 100.0, &[])]);
1237 let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1238 let r = report(&b, &g, &st, &HashMap::new(), &HashMap::new(), 5);
1239 assert!(near(r.ev, 0.0));
1240 }
1241
1242 #[test]
1243 fn fifty_fifty_earns_half_when_in_progress() {
1244 let r = one(EarningRule::FiftyFifty, DeliverableStatus::InProgress, None);
1245 assert!(near(r.ev, 50.0));
1246 }
1247
1248 #[test]
1249 fn fifty_fifty_earns_half_when_any_percent_reported() {
1250 let r = one(EarningRule::FiftyFifty, DeliverableStatus::Ready, Some(10));
1251 assert!(near(r.ev, 50.0));
1252 }
1253
1254 #[test]
1255 fn fifty_fifty_earns_full_when_complete() {
1256 let r = one(EarningRule::FiftyFifty, DeliverableStatus::Complete, None);
1257 assert!(near(r.ev, 100.0));
1258 }
1259
1260 #[test]
1261 fn weighted_earns_the_reported_percent() {
1262 let r = one(
1263 EarningRule::Weighted,
1264 DeliverableStatus::InProgress,
1265 Some(30),
1266 );
1267 assert!(near(r.ev, 30.0));
1268 }
1269
1270 #[test]
1271 fn weighted_earns_nothing_without_a_report() {
1272 let r = one(EarningRule::Weighted, DeliverableStatus::InProgress, None);
1273 assert!(near(r.ev, 0.0));
1274 }
1275
1276 #[test]
1277 fn weighted_ignores_reported_percent_once_complete() {
1278 let r = one(EarningRule::Weighted, DeliverableStatus::Complete, Some(40));
1279 assert!(near(r.ev, 100.0));
1280 }
1281
1282 #[test]
1283 fn failed_deliverable_earns_nothing_under_zero_hundred() {
1284 let r = one(
1285 EarningRule::ZeroHundred,
1286 DeliverableStatus::Failed { reason: "x".into() },
1287 None,
1288 );
1289 assert!(near(r.ev, 0.0));
1290 }
1291
1292 fn ac_for(actual: Option<f32>, leased: f32, rate: Option<f64>) -> f32 {
1293 let mut br = row("a", 0.0, 10.0, 100.0);
1294 br.cost_rate = rate.unwrap_or(1.0) as f32;
1295 let b = baseline(vec![br]);
1296 let g = graph(vec![deliverable("a", 100.0, &[])]);
1297 let actuals = HashMap::from([(
1298 "a".to_string(),
1299 Actuals {
1300 actual_hours: actual,
1301 leased_hours: leased,
1302 ..Actuals::default()
1303 },
1304 )]);
1305 report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5).ac
1306 }
1307
1308 #[test]
1309 fn ac_prefers_reported_hours_over_leased() {
1310 assert!(near(ac_for(Some(7.0), 3.0, None), 7.0));
1311 }
1312
1313 #[test]
1314 fn ac_falls_back_to_leased_hours() {
1315 assert!(near(ac_for(None, 3.0, None), 3.0));
1316 }
1317
1318 #[test]
1319 fn ac_is_scaled_by_cost_rate() {
1320 assert!(near(ac_for(Some(4.0), 0.0, Some(2.5)), 10.0));
1321 }
1322
1323 #[test]
1324 fn baseline_budget_is_effort_times_cost_rate() {
1325 let mut d = deliverable("a", 10.0, &[]);
1326 d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1327 let g = graph(vec![d]);
1328 let cpm = compute_cpm(&g).unwrap();
1329 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1330 assert!(near(b.bac, 30.0));
1331 }
1332
1333 #[test]
1334 fn baseline_defaults_cost_rate_to_one() {
1335 let g = graph(vec![deliverable("a", 10.0, &[])]);
1336 let cpm = compute_cpm(&g).unwrap();
1337 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1338 assert!(near(b.bac, 10.0));
1339 }
1340
1341 #[test]
1342 fn baseline_excludes_synthetic_endpoints() {
1343 let g = graph(vec![deliverable("a", 10.0, &[])]);
1344 let cpm = compute_cpm(&g).unwrap();
1345 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1346 assert_eq!(b.rows.len(), 1);
1347 }
1348
1349 #[test]
1350 fn baseline_freezes_cpm_start_and_finish() {
1351 let g = graph(vec![
1352 deliverable("a", 10.0, &[]),
1353 deliverable("b", 5.0, &["a"]),
1354 ]);
1355 let cpm = compute_cpm(&g).unwrap();
1356 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1357 let rb = b.rows.iter().find(|r| r.id == "b").unwrap();
1358 assert!(near(rb.es, 10.0) && near(rb.ef, 15.0));
1359 }
1360
1361 #[test]
1362 fn baseline_records_finish_hours() {
1363 let g = graph(vec![
1364 deliverable("a", 10.0, &[]),
1365 deliverable("b", 5.0, &["a"]),
1366 ]);
1367 let cpm = compute_cpm(&g).unwrap();
1368 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1369 assert!(near(b.finish_hours, 15.0));
1370 }
1371
1372 #[test]
1373 fn baseline_rejects_negative_cost_rate() {
1374 let mut d = deliverable("a", 10.0, &[]);
1375 d.metadata = serde_json::json!({ "cost_rate": -1.0 });
1376 let g = graph(vec![d]);
1377 let cpm = compute_cpm(&g).unwrap();
1378 let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1379 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1380 }
1381
1382 #[test]
1383 fn baseline_rejects_non_numeric_cost_rate() {
1384 let mut d = deliverable("a", 10.0, &[]);
1385 d.metadata = serde_json::json!({ "cost_rate": "free" });
1386 let g = graph(vec![d]);
1387 let cpm = compute_cpm(&g).unwrap();
1388 let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1389 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1390 }
1391
1392 #[test]
1393 fn baseline_rejects_invalid_calendar() {
1394 let g = graph(vec![deliverable("a", 10.0, &[])]);
1395 let cpm = compute_cpm(&g).unwrap();
1396 let c = Calendar {
1397 hours_per_day: 0.0,
1398 ..Calendar::default()
1399 };
1400 let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), Some(c), 1);
1401 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1402 }
1403
1404 #[test]
1405 fn budget_in_ev_report_is_baseline_budget() {
1406 let r = textbook();
1407 assert!(near(r.bac, 100.0));
1408 }
1409
1410 #[test]
1411 fn textbook_pv() {
1412 assert!(near(textbook().pv, 50.0));
1413 }
1414
1415 #[test]
1416 fn textbook_ev() {
1417 assert!(near(textbook().ev, 40.0));
1418 }
1419
1420 #[test]
1421 fn textbook_ac() {
1422 assert!(near(textbook().ac, 48.0));
1423 }
1424
1425 #[test]
1426 fn textbook_sv() {
1427 assert!(near(textbook().sv, -10.0));
1428 }
1429
1430 #[test]
1431 fn textbook_cv() {
1432 assert!(near(textbook().cv, -8.0));
1433 }
1434
1435 #[test]
1436 fn textbook_spi() {
1437 assert!(near(textbook().spi.unwrap(), 0.8));
1438 }
1439
1440 #[test]
1441 fn textbook_cpi() {
1442 assert!(near(textbook().cpi.unwrap(), 0.8333));
1443 }
1444
1445 #[test]
1446 fn textbook_eac() {
1447 assert!(near(textbook().eac.unwrap(), 120.0));
1448 }
1449
1450 #[test]
1451 fn textbook_etc() {
1452 assert!(near(textbook().etc.unwrap(), 72.0));
1453 }
1454
1455 #[test]
1456 fn textbook_vac() {
1457 assert!(near(textbook().vac.unwrap(), -20.0));
1458 }
1459
1460 #[test]
1461 fn textbook_tcpi() {
1462 assert!(near(textbook().tcpi.unwrap(), 60.0 / 52.0));
1463 }
1464
1465 #[test]
1466 fn textbook_has_no_undefined_fields() {
1467 assert!(textbook().undefined.is_empty());
1468 }
1469
1470 fn undefined_reason(r: &EvReport, field: &str) -> Option<String> {
1471 r.undefined
1472 .iter()
1473 .find(|u| u.field == field)
1474 .map(|u| u.reason.clone())
1475 }
1476
1477 #[test]
1478 fn spi_is_null_when_pv_is_zero() {
1479 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1480 let g = graph(vec![deliverable("a", 100.0, &[])]);
1481 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1482 assert!(r.spi.is_none());
1483 }
1484
1485 #[test]
1486 fn spi_zero_pv_reason_is_explained() {
1487 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1488 let g = graph(vec![deliverable("a", 100.0, &[])]);
1489 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1490 assert_eq!(
1491 undefined_reason(&r, "spi").as_deref(),
1492 Some("PV is 0 before any work was planned")
1493 );
1494 }
1495
1496 #[test]
1497 fn cpi_is_null_when_ac_is_zero() {
1498 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1499 assert!(r.cpi.is_none());
1500 }
1501
1502 #[test]
1503 fn cpi_zero_ac_reason_is_explained() {
1504 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1505 assert_eq!(
1506 undefined_reason(&r, "cpi").as_deref(),
1507 Some("AC is 0, no cost recorded yet")
1508 );
1509 }
1510
1511 #[test]
1512 fn eac_is_null_when_cpi_is_null() {
1513 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1514 assert!(r.eac.is_none());
1515 }
1516
1517 #[test]
1518 fn dependents_of_eac_are_null_and_explained() {
1519 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1520 assert!(r.etc.is_none() && r.vac.is_none());
1521 assert!(undefined_reason(&r, "etc").is_some() && undefined_reason(&r, "vac").is_some());
1522 }
1523
1524 #[test]
1525 fn eac_is_null_when_cpi_is_zero() {
1526 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1528 let g = graph(vec![deliverable("a", 100.0, &[])]);
1529 let actuals = HashMap::from([(
1530 "a".to_string(),
1531 Actuals {
1532 actual_hours: Some(10.0),
1533 ..Actuals::default()
1534 },
1535 )]);
1536 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1537 assert!(r.eac.is_none());
1538 }
1539
1540 #[test]
1541 fn tcpi_is_null_when_bac_equals_ac() {
1542 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1543 let g = graph(vec![deliverable("a", 100.0, &[])]);
1544 let actuals = HashMap::from([(
1545 "a".to_string(),
1546 Actuals {
1547 actual_hours: Some(100.0),
1548 ..Actuals::default()
1549 },
1550 )]);
1551 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1552 assert!(r.tcpi.is_none());
1553 }
1554
1555 #[test]
1556 fn tcpi_zero_denominator_reason_is_explained() {
1557 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1558 let g = graph(vec![deliverable("a", 100.0, &[])]);
1559 let actuals = HashMap::from([(
1560 "a".to_string(),
1561 Actuals {
1562 actual_hours: Some(100.0),
1563 ..Actuals::default()
1564 },
1565 )]);
1566 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1567 assert_eq!(
1568 undefined_reason(&r, "tcpi").as_deref(),
1569 Some("BAC - AC is 0, no budget remains")
1570 );
1571 }
1572
1573 #[test]
1574 fn empty_baseline_yields_all_finite_values() {
1575 let b = baseline(vec![]);
1576 let g = graph(vec![]);
1577 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1578 assert!(r.pv.is_finite() && r.ev.is_finite() && r.ac.is_finite());
1579 }
1580
1581 #[test]
1582 fn rows_report_per_deliverable_ev() {
1583 let r = textbook();
1584 let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1585 assert!(near(a.ev, 40.0));
1586 }
1587
1588 #[test]
1589 fn rows_report_status() {
1590 let r = textbook();
1591 let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1592 assert_eq!(a.status, DeliverableStatus::InProgress);
1593 }
1594
1595 #[test]
1596 fn missing_status_defaults_to_pending() {
1597 let r = textbook();
1598 let b = r.rows.iter().find(|x| x.id == "b").unwrap();
1599 assert_eq!(b.status, DeliverableStatus::Pending);
1600 }
1601
1602 #[test]
1603 fn critical_float_consumed_is_forecast_overrun() {
1604 let mut b = baseline(vec![row("a", 0.0, 5.0, 12.0)]);
1606 b.finish_hours = 5.0;
1607 let g = graph(vec![deliverable("a", 12.0, &[])]);
1608 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1609 assert!(near(r.critical_float_consumed_hours, 7.0));
1610 }
1611
1612 #[test]
1613 fn critical_float_consumed_never_negative() {
1614 let mut b = baseline(vec![row("a", 0.0, 20.0, 12.0)]);
1615 b.finish_hours = 20.0;
1616 let g = graph(vec![deliverable("a", 12.0, &[])]);
1617 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1618 assert!(near(r.critical_float_consumed_hours, 0.0));
1619 }
1620
1621 fn snap(spi: f32, cpi: f32) -> EvSummary {
1622 EvSummary {
1623 baseline_number: 1,
1624 spi: Some(spi),
1625 cpi: Some(cpi),
1626 backfilled: false,
1627 }
1628 }
1629
1630 fn alerts_with(prev: &[EvSummary]) -> Vec<String> {
1631 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1632 let g = graph(vec![deliverable("a", 100.0, &[])]);
1633 let as_of = b.start + Duration::hours(5);
1634 compute_ev(
1635 &b,
1636 &g,
1637 &HashMap::new(),
1638 &HashMap::new(),
1639 &HashMap::new(),
1640 as_of,
1641 prev,
1642 )
1643 .unwrap()
1644 .alerts
1645 }
1646
1647 #[test]
1648 fn spi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1649 let a = alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0), snap(0.85, 1.0)]);
1650 assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
1651 }
1652
1653 #[test]
1654 fn alert_does_not_fire_on_a_single_low_snapshot() {
1655 assert!(alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0)]).is_empty());
1656 }
1657
1658 #[test]
1659 fn alert_does_not_fire_with_one_snapshot() {
1660 assert!(alerts_with(&[snap(0.5, 0.5)]).is_empty());
1661 }
1662
1663 #[test]
1664 fn cpi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1665 let a = alerts_with(&[snap(1.0, 0.7), snap(1.0, 0.89)]);
1666 assert_eq!(a, vec!["CPI_BELOW_0_9".to_string()]);
1667 }
1668
1669 #[test]
1670 fn alert_ignores_snapshots_with_undefined_metric() {
1671 let none = EvSummary {
1672 baseline_number: 1,
1673 spi: None,
1674 cpi: None,
1675 backfilled: false,
1676 };
1677 assert!(alerts_with(&[snap(0.5, 0.5), none]).is_empty());
1678 }
1679
1680 #[test]
1681 fn report_is_independent_of_map_insertion_order() {
1682 let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 0.0, 10.0, 50.0)]);
1683 let g = graph(vec![
1684 deliverable("a", 50.0, &[]),
1685 deliverable("b", 50.0, &[]),
1686 ]);
1687 let act = |h: f32| Actuals {
1688 actual_hours: Some(h),
1689 ..Actuals::default()
1690 };
1691 let fwd = HashMap::from([("a".to_string(), act(3.0)), ("b".to_string(), act(4.0))]);
1692 let mut rev = HashMap::new();
1693 rev.insert("b".to_string(), act(4.0));
1694 rev.insert("a".to_string(), act(3.0));
1695 let e = HashMap::new();
1696 assert_eq!(
1697 report(&b, &g, &e, &e2(), &fwd, 5),
1698 report(&b, &g, &e, &e2(), &rev, 5)
1699 );
1700 }
1701
1702 fn e2() -> HashMap<String, EarningRule> {
1703 HashMap::new()
1704 }
1705
1706 #[test]
1707 fn removed_deliverable_keeps_baselined_cost_rate() {
1708 let mut br = row("a", 0.0, 10.0, 100.0);
1709 br.cost_rate = 2.0;
1710 let b = baseline(vec![br]);
1711 let g = graph(vec![]);
1712 let actuals = HashMap::from([(
1713 "a".to_string(),
1714 Actuals {
1715 actual_hours: Some(5.0),
1716 ..Actuals::default()
1717 },
1718 )]);
1719 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1720 assert!(near(r.ac, 10.0));
1721 }
1722
1723 #[test]
1724 fn rate_edit_after_baseline_does_not_change_ac() {
1725 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1726 let mut d = deliverable("a", 100.0, &[]);
1727 d.metadata = serde_json::json!({ "cost_rate": 9.0 });
1728 let actuals = HashMap::from([(
1729 "a".to_string(),
1730 Actuals {
1731 actual_hours: Some(5.0),
1732 ..Actuals::default()
1733 },
1734 )]);
1735 let r = report(
1736 &b,
1737 &graph(vec![d]),
1738 &HashMap::new(),
1739 &HashMap::new(),
1740 &actuals,
1741 5,
1742 );
1743 assert!(near(r.ac, 5.0));
1744 }
1745
1746 #[test]
1747 fn invalid_rate_added_after_baseline_does_not_fail_compute() {
1748 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1749 let mut d = deliverable("a", 100.0, &[]);
1750 d.metadata = serde_json::json!({ "cost_rate": -4.0 });
1751 let r = compute_ev(
1752 &b,
1753 &graph(vec![d]),
1754 &HashMap::new(),
1755 &HashMap::new(),
1756 &HashMap::new(),
1757 b.start,
1758 &[],
1759 );
1760 assert!(r.is_ok());
1761 }
1762
1763 #[test]
1764 fn baseline_captures_cost_rate_per_row() {
1765 let mut d = deliverable("a", 10.0, &[]);
1766 d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1767 let g = graph(vec![d]);
1768 let cpm = compute_cpm(&g).unwrap();
1769 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1770 assert!(near(b.rows[0].cost_rate, 3.0));
1771 }
1772
1773 #[test]
1774 fn deliverable_added_after_baseline_is_excluded_and_listed() {
1775 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1776 let g = graph(vec![
1777 deliverable("z", 40.0, &[]),
1778 deliverable("a", 100.0, &[]),
1779 deliverable("m", 40.0, &[]),
1780 ]);
1781 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1782 assert_eq!(
1783 r.excluded_unbaselined,
1784 vec!["m".to_string(), "z".to_string()]
1785 );
1786 }
1787
1788 #[test]
1789 fn unbaselined_deliverable_adds_nothing_to_bac() {
1790 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1791 let g = graph(vec![
1792 deliverable("a", 100.0, &[]),
1793 deliverable("z", 40.0, &[]),
1794 ]);
1795 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1796 assert!(near(r.bac, 100.0));
1797 }
1798
1799 #[test]
1800 fn negative_reported_hours_fall_back_to_leased() {
1801 assert!(near(ac_for(Some(-2.0), 3.0, None), 3.0));
1802 }
1803
1804 #[test]
1805 fn non_finite_reported_hours_fall_back_to_leased() {
1806 assert!(near(ac_for(Some(f32::NAN), 3.0, None), 3.0));
1807 }
1808
1809 #[test]
1810 fn as_of_before_start_gives_zero_pv() {
1811 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1812 let g = graph(vec![deliverable("a", 100.0, &[])]);
1813 let r = report(
1814 &b,
1815 &g,
1816 &HashMap::new(),
1817 &HashMap::new(),
1818 &HashMap::new(),
1819 -5,
1820 );
1821 assert!(near(r.pv, 0.0));
1822 }
1823
1824 #[test]
1825 fn as_of_before_start_leaves_spi_null_with_reason() {
1826 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1827 let g = graph(vec![deliverable("a", 100.0, &[])]);
1828 let r = report(
1829 &b,
1830 &g,
1831 &HashMap::new(),
1832 &HashMap::new(),
1833 &HashMap::new(),
1834 -5,
1835 );
1836 assert!(r.spi.is_none() && undefined_reason(&r, "spi").is_some());
1837 }
1838
1839 #[test]
1840 fn pv_is_flat_over_a_weekend_on_a_working_calendar() {
1841 let mut b = baseline(vec![row("a", 0.0, 80.0, 100.0)]);
1843 b.calendar = Some(Calendar::default());
1844 let g = graph(vec![deliverable("a", 100.0, &[])]);
1845 let at_fri_end = compute_ev(
1846 &b,
1847 &g,
1848 &HashMap::new(),
1849 &HashMap::new(),
1850 &HashMap::new(),
1851 at(2026, 1, 9, 12),
1852 &[],
1853 )
1854 .unwrap();
1855 let mon = compute_ev(
1856 &b,
1857 &g,
1858 &HashMap::new(),
1859 &HashMap::new(),
1860 &HashMap::new(),
1861 at(2026, 1, 12, 0),
1862 &[],
1863 )
1864 .unwrap();
1865 assert!(near(at_fri_end.pv, mon.pv));
1866 }
1867
1868 #[test]
1869 fn eac_reason_when_cpi_is_zero_is_explained() {
1870 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1871 let g = graph(vec![deliverable("a", 100.0, &[])]);
1872 let actuals = HashMap::from([(
1873 "a".to_string(),
1874 Actuals {
1875 actual_hours: Some(10.0),
1876 ..Actuals::default()
1877 },
1878 )]);
1879 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1880 assert_eq!(
1881 undefined_reason(&r, "eac").as_deref(),
1882 Some("CPI is 0, nothing earned for the cost spent")
1883 );
1884 }
1885
1886 #[test]
1887 fn tcpi_is_negative_when_ac_exceeds_bac() {
1888 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1889 let g = graph(vec![deliverable("a", 100.0, &[])]);
1890 let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1891 let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1892 let actuals = HashMap::from([(
1893 "a".to_string(),
1894 Actuals {
1895 earned_pct: Some(50),
1896 actual_hours: Some(150.0),
1897 ..Actuals::default()
1898 },
1899 )]);
1900 let r = report(&b, &g, &st, &rules, &actuals, 5);
1901 assert!(near(r.tcpi.unwrap(), -1.0));
1902 }
1903
1904 #[test]
1905 fn f32_overflow_in_a_ratio_becomes_undefined() {
1906 let b = baseline(vec![
1907 row("a", 0.0, 10.0, 3.0e38),
1908 row("b", 0.0, 10.0, 3.0e38),
1909 ]);
1910 let g = graph(vec![deliverable("a", 1.0, &[]), deliverable("b", 1.0, &[])]);
1911 let st = statuses(&[("a", DeliverableStatus::Complete)]);
1912 let actuals = HashMap::from([(
1913 "a".to_string(),
1914 Actuals {
1915 actual_hours: Some(1.0e-30),
1916 ..Actuals::default()
1917 },
1918 )]);
1919 let r = report(&b, &g, &st, &HashMap::new(), &actuals, 5);
1920 assert!(r.cpi.is_none() && undefined_reason(&r, "cpi").is_some());
1921 }
1922
1923 #[test]
1924 fn alert_needs_both_of_the_two_previous_snapshots() {
1925 assert!(alerts_with(&[snap(0.5, 1.0), snap(0.5, 1.0), snap(1.0, 1.0)]).is_empty());
1927 }
1928
1929 #[test]
1930 fn elapsed_matches_naive_minute_loop_over_random_spans() {
1931 let mut seed: u64 = 0x5eed_1234_abcd_ef01;
1932 let mut next = move |n: u64| {
1933 seed = seed
1934 .wrapping_mul(6364136223846793005)
1935 .wrapping_add(1442695040888963407);
1936 (seed >> 33) % n
1937 };
1938 let names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
1939 let mut mismatches = Vec::new();
1940 for case in 0..60 {
1941 let start = at(2026, 1, 3, 0) + Duration::minutes(next(60 * 24 * 14) as i64);
1942 let span = Duration::minutes(next(60 * 24 * 40) as i64 + 1);
1943 let off = next(2881) as i32 - 1440;
1944 let n_days = 1 + next(7) as usize;
1945 let first = next(7) as usize;
1946 let workdays: Vec<String> = (0..n_days)
1947 .map(|i| names[(first + i) % 7].to_string())
1948 .collect();
1949 let hpd = [8.0_f32, 6.5, 24.0][next(3) as usize];
1950 let c = Calendar {
1951 hours_per_day: hpd,
1952 workdays: workdays.clone(),
1953 utc_offset_minutes: off,
1954 };
1955 let got = elapsed_hours(start, start + span, Some(&c)).unwrap();
1956 let mut naive = 0u64;
1957 let window = (f64::from(hpd) * 60.0) as i64;
1958 let mut m = start;
1959 let end = start + span;
1960 while m < end {
1961 let local = m + Duration::minutes(i64::from(off));
1962 let name = names[local.weekday().num_days_from_monday() as usize];
1963 let mins = i64::from(local.hour()) * 60 + i64::from(local.minute());
1964 if workdays.iter().any(|w| w == name) && mins < window {
1965 naive += 1;
1966 }
1967 m += Duration::minutes(1);
1968 }
1969 let want = naive as f32 / 60.0;
1970 if (got - want).abs() > 1e-2 {
1971 mismatches.push((case, got, want));
1972 }
1973 }
1974 assert!(mismatches.is_empty(), "{mismatches:?}");
1975 }
1976
1977 #[test]
1980 fn trend_alerts_fire_spi_when_two_latest_are_low() {
1981 let a = trend_alerts(&[snap(0.8, 1.0), snap(0.85, 1.0)], 1);
1982 assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
1983 }
1984
1985 #[test]
1986 fn baseline_rejects_budget_total_beyond_f32() {
1987 let heavy = |id: &str| {
1988 let mut d = deliverable(id, 1.0, &[]);
1989 d.metadata = serde_json::json!({ "cost_rate": 3.0e38 });
1990 d
1991 };
1992 let g = graph(vec![heavy("a"), heavy("b")]);
1993 let cpm = compute_cpm(&g).unwrap();
1994 let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1995 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1996 }
1997
1998 #[test]
1999 fn ev_rejects_actual_cost_beyond_f32() {
2000 let mut br = row("a", 0.0, 10.0, 100.0);
2001 br.cost_rate = 3.0e38;
2002 let b = baseline(vec![br]);
2003 let g = graph(vec![deliverable("a", 100.0, &[])]);
2004 let actuals = HashMap::from([(
2005 "a".to_string(),
2006 Actuals {
2007 actual_hours: Some(1.0e6),
2008 ..Actuals::default()
2009 },
2010 )]);
2011 let as_of = b.start + Duration::hours(5);
2012 let e = compute_ev(
2013 &b,
2014 &g,
2015 &HashMap::new(),
2016 &HashMap::new(),
2017 &actuals,
2018 as_of,
2019 &[],
2020 );
2021 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
2022 }
2023
2024 #[test]
2025 fn calendar_rejects_unknown_fields() {
2026 let c = serde_json::from_value::<Calendar>(serde_json::json!({ "hours": 8 }));
2027 assert!(c.is_err());
2028 }
2029
2030 fn summary_of(r: &EvReport) -> SnapshotSummary {
2031 SnapshotSummary::from_report(r, r.as_of)
2032 }
2033
2034 #[test]
2035 fn snapshot_summary_carries_report_spi() {
2036 let r = textbook();
2037 assert_eq!(summary_of(&r).spi, r.spi);
2038 }
2039
2040 #[test]
2041 fn snapshot_markdown_starts_with_header_row() {
2042 let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2043 assert!(md.starts_with("| date | PV | EV | AC | SPI | CPI | EAC |\n"));
2044 }
2045
2046 #[test]
2047 fn snapshot_markdown_renders_one_row_per_snapshot() {
2048 let s = summary_of(&textbook());
2049 let md = render_snapshots_markdown(&[s.clone(), s]);
2050 assert_eq!(md.lines().count(), 4);
2051 }
2052
2053 #[test]
2054 fn snapshot_markdown_row_shows_textbook_values() {
2055 let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2056 assert_eq!(
2057 md.lines().nth(2),
2058 Some("| 2026-01-05T10:00Z | 50.00 | 40.00 | 48.00 | 0.80 | 0.83 | 120.00 |")
2059 );
2060 }
2061
2062 #[test]
2063 fn snapshot_markdown_renders_undefined_ratio_as_na() {
2064 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2065 let md = render_snapshots_markdown(&[summary_of(&r)]);
2066 assert!(
2067 md.lines()
2068 .nth(2)
2069 .is_some_and(|l| l.ends_with("| n/a | n/a |"))
2070 );
2071 }
2072
2073 #[test]
2074 fn alerts_ignore_a_backfilled_new_reading() {
2075 let mut backfilled = snap(0.5, 1.0);
2076 backfilled.backfilled = true;
2077 assert!(trend_alerts(&[snap(0.5, 1.0), backfilled], 1).is_empty());
2078 }
2079
2080 #[test]
2081 fn snapshot_more_than_an_hour_behind_its_taken_at_is_backfilled() {
2082 let r = textbook();
2083 assert!(SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(61)).backfilled);
2084 }
2085
2086 #[test]
2087 fn snapshot_within_an_hour_of_its_taken_at_is_not_backfilled() {
2088 let r = textbook();
2089 assert!(!SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(60)).backfilled);
2090 }
2091
2092 #[test]
2093 fn removed_row_earns_its_frozen_percent() {
2094 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2095 let actuals = HashMap::from([(
2096 "a".to_string(),
2097 Actuals {
2098 frozen_pct: Some(40),
2099 ..Actuals::default()
2100 },
2101 )]);
2102 let r = report(&b, &graph(vec![]), &HashMap::new(), &e2(), &actuals, 5);
2103 assert!(near(r.ev, 40.0));
2104 }
2105
2106 #[test]
2107 fn removed_row_without_frozen_percent_earns_nothing() {
2108 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2109 let st = statuses(&[("a", DeliverableStatus::Complete)]);
2110 let r = report(&b, &graph(vec![]), &st, &e2(), &HashMap::new(), 5);
2111 assert!(near(r.ev, 0.0));
2112 }
2113
2114 #[test]
2115 fn unbaselined_actuals_count_in_ac_at_rate_one() {
2116 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2117 let g = graph(vec![
2118 deliverable("a", 100.0, &[]),
2119 deliverable("n", 5.0, &[]),
2120 ]);
2121 let actuals = HashMap::from([(
2122 "n".to_string(),
2123 Actuals {
2124 actual_hours: Some(3.0),
2125 ..Actuals::default()
2126 },
2127 )]);
2128 let r = report(&b, &g, &HashMap::new(), &e2(), &actuals, 5);
2129 assert!(near(r.ac, 3.0));
2130 }
2131
2132 #[test]
2133 fn frozen_pct_of_a_complete_deliverable_is_100() {
2134 let pct = frozen_pct(EarningRule::ZeroHundred, &DeliverableStatus::Complete, None);
2135 assert_eq!(pct, 100);
2136 }
2137
2138 #[test]
2139 fn alerts_ignore_snapshots_from_previous_baseline() {
2140 assert!(trend_alerts(&[snap(0.5, 0.5), snap(0.5, 0.5)], 2).is_empty());
2141 }
2142
2143 #[test]
2144 fn alerts_use_the_two_latest_snapshots_of_the_current_baseline() {
2145 let mut current = snap(0.5, 1.0);
2146 current.baseline_number = 2;
2147 let a = trend_alerts(&[current.clone(), snap(1.0, 1.0), current], 2);
2148 assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
2149 }
2150
2151 #[test]
2152 fn snapshot_summary_explains_undefined_ratios() {
2153 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2154 assert_eq!(summary_of(&r).undefined, r.undefined);
2155 }
2156
2157 #[test]
2158 fn summary_without_undefined_field_decodes() {
2159 let mut v = serde_json::to_value(summary_of(&textbook())).unwrap();
2160 v.as_object_mut().unwrap().remove("undefined");
2161 assert!(serde_json::from_value::<SnapshotSummary>(v).is_ok());
2162 }
2163}