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