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 != 0.0).then(|| ev / pv),
607 "PV is 0 before any work was planned",
608 );
609 let cpi = ratio(
610 "cpi",
611 (ac != 0.0).then(|| ev / ac),
612 "AC is 0, no cost recorded yet",
613 );
614 let eac_reason = if cpi.is_none() {
615 "CPI is undefined"
616 } else {
617 "CPI is 0, nothing earned for the cost spent"
618 };
619 let eac = ratio(
620 "eac",
621 cpi.filter(|c| *c != 0.0).map(|c| bac / c),
622 eac_reason,
623 );
624 let etc = ratio("etc", eac.map(|e| e - ac), "EAC is undefined");
625 let vac = ratio("vac", eac.map(|e| bac - e), "EAC is undefined");
626 let tcpi = ratio(
627 "tcpi",
628 ((bac - ac).abs() > 1e-9 * bac.max(1.0)).then(|| (bac - ev) / (bac - ac)),
629 "BAC - AC is 0, no budget remains",
630 );
631 let out = |v: Option<f64>| v.map(|x| x as f32);
632
633 let cpm = compute_cpm(graph)?;
634 let critical_float = (finish_of(&cpm) - baseline.finish_hours).max(0.0);
635
636 let alerts = trend_alerts(previous_snapshots, baseline.number);
637 for (field, v) in [
638 ("pv", pv),
639 ("ev", ev),
640 ("ac", ac),
641 ("sv", ev - pv),
642 ("cv", ev - ac),
643 ] {
644 if !(v as f32).is_finite() {
645 return Err(PlannerError::InvalidGraph {
646 reason: format!(
647 "earned-value total {field} exceeds the f32 range (check cost_rate and \
648 reported hours)"
649 ),
650 });
651 }
652 }
653
654 let report = EvReport {
655 as_of,
656 baseline_number: baseline.number,
657 backfilled: baseline.backfilled,
658 bac: baseline.bac,
659 pv: pv as f32,
660 ev: ev as f32,
661 ac: ac as f32,
662 sv: (ev - pv) as f32,
663 cv: (ev - ac) as f32,
664 spi: out(spi),
665 cpi: out(cpi),
666 eac: out(eac),
667 etc: out(etc),
668 vac: out(vac),
669 tcpi: out(tcpi),
670 undefined,
671 rows,
672 critical_float_consumed_hours: critical_float,
673 alerts,
674 excluded_unbaselined: {
675 let known: std::collections::HashSet<&str> =
676 baseline.rows.iter().map(|r| r.id.as_str()).collect();
677 let mut v: Vec<String> = graph
678 .deliverables
679 .iter()
680 .filter(|d| d.id != START_ID && d.id != FINISH_ID && !known.contains(d.id.as_str()))
681 .map(|d| d.id.clone())
682 .collect();
683 v.sort();
684 v
685 },
686 archived: false,
687 active_plan: None,
688 };
689 Ok(report)
690}
691
692pub const MAX_BASELINE_REASON_CHARS: usize = crate::plan::MAX_EVIDENCE_CHARS;
695
696pub const SNAPSHOT_EXPORT_LIMIT: usize = 100;
698
699#[derive(Debug, Clone, PartialEq)]
701pub struct BaselineRequest {
702 pub plan_id: PlanId,
703 pub start: Option<DateTime<Utc>>,
705 pub calendar: Option<Calendar>,
707 pub reason: Option<String>,
710 pub backfill_from: Option<DateTime<Utc>>,
714}
715
716impl BaselineRequest {
717 pub fn new(plan_id: PlanId) -> Self {
718 Self {
719 plan_id,
720 start: None,
721 calendar: None,
722 reason: None,
723 backfill_from: None,
724 }
725 }
726
727 pub fn with_start(mut self, start: DateTime<Utc>) -> Self {
728 self.start = Some(start);
729 self
730 }
731
732 pub fn with_backfill_from(mut self, backfill_from: DateTime<Utc>) -> Self {
733 self.backfill_from = Some(backfill_from);
734 self
735 }
736
737 pub fn with_calendar(mut self, calendar: Calendar) -> Self {
738 self.calendar = Some(calendar);
739 self
740 }
741
742 pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
743 self.reason = Some(reason.into());
744 self
745 }
746}
747
748#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750pub struct BaselineOutcome {
751 pub plan_id: String,
752 pub baseline_number: u32,
753 pub start: DateTime<Utc>,
754 pub calendar: Option<Calendar>,
755 pub bac: f32,
756 pub finish_hours: f32,
757 pub deliverable_count: usize,
758 pub reason: Option<String>,
759 #[serde(default)]
761 pub backfilled: bool,
762}
763
764pub const FIRST_EXECUTION_REASON: &str = "first_execution";
767
768#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
771pub enum BaselineMode {
772 #[default]
775 Auto,
776 Refuse,
779}
780
781#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
784pub struct BaselineTaken {
785 pub baseline_number: u32,
787 pub as_of: DateTime<Utc>,
789 pub reason: String,
791}
792
793#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
796pub struct PlanBaseline {
797 pub baseline_number: u32,
798 pub as_of: DateTime<Utc>,
799}
800
801#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
803#[serde(rename_all = "snake_case")]
804pub enum SnapshotFormat {
805 #[default]
807 Json,
808 Markdown,
810}
811
812#[derive(Debug, Clone, PartialEq)]
814pub struct SnapshotRequest {
815 pub plan_id: PlanId,
816 pub as_of: Option<DateTime<Utc>>,
821 pub format: SnapshotFormat,
822}
823
824impl SnapshotRequest {
825 pub fn new(plan_id: PlanId) -> Self {
826 Self {
827 plan_id,
828 as_of: None,
829 format: SnapshotFormat::default(),
830 }
831 }
832
833 pub fn with_as_of(mut self, as_of: DateTime<Utc>) -> Self {
834 self.as_of = Some(as_of);
835 self
836 }
837
838 pub fn with_format(mut self, format: SnapshotFormat) -> Self {
839 self.format = format;
840 self
841 }
842}
843
844#[derive(Debug, Clone, PartialEq, Serialize)]
846pub struct SnapshotOutcome {
847 pub summary: SnapshotSummary,
848 pub format: SnapshotFormat,
849 pub export: serde_json::Value,
855 pub snapshot_count: usize,
857}
858
859pub fn trend_alerts(snapshots: &[EvSummary], baseline_number: u32) -> Vec<String> {
867 let mut alerts = Vec::new();
868 let current: Vec<&EvSummary> = snapshots
869 .iter()
870 .filter(|s| s.baseline_number == baseline_number && !s.backfilled)
871 .collect();
872 if let [.., a, b] = current.as_slice() {
873 let low = |v: Option<f32>| v.is_some_and(|x| x < 0.9);
874 if low(a.spi) && low(b.spi) {
875 alerts.push("SPI_BELOW_0_9".to_string());
876 }
877 if low(a.cpi) && low(b.cpi) {
878 alerts.push("CPI_BELOW_0_9".to_string());
879 }
880 }
881 alerts
882}
883
884#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888pub struct SnapshotSummary {
889 pub taken_at: DateTime<Utc>,
890 pub as_of: DateTime<Utc>,
891 pub baseline_number: u32,
892 pub bac: f32,
893 pub pv: f32,
894 pub ev: f32,
895 pub ac: f32,
896 pub sv: f32,
897 pub cv: f32,
898 pub spi: Option<f32>,
899 pub cpi: Option<f32>,
900 pub eac: Option<f32>,
901 pub etc: Option<f32>,
902 pub vac: Option<f32>,
903 pub tcpi: Option<f32>,
904 #[serde(default)]
907 pub undefined: Vec<Undefined>,
908 pub alerts: Vec<String>,
909 #[serde(default)]
914 pub backfilled: bool,
915}
916
917pub const BACKFILL_TOLERANCE_HOURS: i64 = 1;
920
921pub fn is_backfilled(as_of: DateTime<Utc>, taken_at: DateTime<Utc>) -> bool {
923 as_of < taken_at - Duration::hours(BACKFILL_TOLERANCE_HOURS)
924}
925
926impl SnapshotSummary {
927 pub fn from_report(report: &EvReport, taken_at: DateTime<Utc>) -> Self {
929 Self {
930 taken_at,
931 as_of: report.as_of,
932 baseline_number: report.baseline_number,
933 bac: report.bac,
934 pv: report.pv,
935 ev: report.ev,
936 ac: report.ac,
937 sv: report.sv,
938 cv: report.cv,
939 spi: report.spi,
940 cpi: report.cpi,
941 eac: report.eac,
942 etc: report.etc,
943 vac: report.vac,
944 tcpi: report.tcpi,
945 undefined: report.undefined.clone(),
946 alerts: report.alerts.clone(),
947 backfilled: is_backfilled(report.as_of, taken_at),
948 }
949 }
950
951 pub fn ratios(&self) -> EvSummary {
953 EvSummary {
954 baseline_number: self.baseline_number,
955 spi: self.spi,
956 cpi: self.cpi,
957 backfilled: self.backfilled,
958 }
959 }
960}
961
962pub fn render_snapshots_markdown(snapshots: &[SnapshotSummary]) -> String {
966 let ratio = |v: Option<f32>| v.map_or_else(|| "n/a".to_string(), |x| format!("{x:.2}"));
967 let mut out = String::from(
968 "| date | PV | EV | AC | SPI | CPI | EAC |\n|---|---:|---:|---:|---:|---:|---:|\n",
969 );
970 for s in snapshots {
971 out.push_str(&format!(
972 "| {} | {:.2} | {:.2} | {:.2} | {} | {} | {} |\n",
973 s.as_of.format("%Y-%m-%dT%H:%MZ"),
974 s.pv,
975 s.ev,
976 s.ac,
977 ratio(s.spi),
978 ratio(s.cpi),
979 ratio(s.eac),
980 ));
981 }
982 out
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988 use chrono::{TimeZone, Timelike};
989
990 const EPS: f32 = 1e-3;
991
992 fn at(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
993 Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap()
994 }
995
996 fn near(a: f32, b: f32) -> bool {
997 (a - b).abs() < EPS
998 }
999
1000 fn deliverable(id: &str, effort: f32, prereqs: &[&str]) -> Deliverable {
1001 Deliverable {
1002 id: id.to_string(),
1003 owned_files: vec![],
1004 prerequisites: prereqs
1005 .iter()
1006 .map(|p| crate::plan::Prerequisite::Id((*p).to_string()))
1007 .collect(),
1008 estimated_effort_hours: Some(effort),
1009 duration_hours: None,
1010 estimate: None,
1011 metadata: serde_json::Value::Null,
1012 milestone: false,
1013 earning_rule: None,
1014 }
1015 }
1016
1017 fn graph(ds: Vec<Deliverable>) -> PlanGraph {
1018 PlanGraph {
1019 deliverables: ds,
1020 max_chained_dispatch: None,
1021 }
1022 }
1023
1024 fn row(id: &str, es: f32, ef: f32, budget: f32) -> BaselineRow {
1025 BaselineRow {
1026 id: id.to_string(),
1027 es,
1028 ef,
1029 budget,
1030 cost_rate: 1.0,
1031 }
1032 }
1033
1034 fn baseline(rows: Vec<BaselineRow>) -> Baseline {
1035 let bac = rows.iter().map(|r| r.budget).sum();
1036 let finish_hours = rows.iter().map(|r| r.ef).fold(0.0, f32::max);
1037 Baseline {
1038 number: 1,
1039 start: at(2026, 1, 5, 0),
1040 calendar: None,
1041 rows,
1042 bac,
1043 finish_hours,
1044 backfilled: false,
1045 }
1046 }
1047
1048 fn statuses(pairs: &[(&str, DeliverableStatus)]) -> HashMap<String, DeliverableStatus> {
1049 pairs
1050 .iter()
1051 .map(|(k, v)| ((*k).to_string(), v.clone()))
1052 .collect()
1053 }
1054
1055 fn report(
1056 b: &Baseline,
1057 g: &PlanGraph,
1058 st: &HashMap<String, DeliverableStatus>,
1059 rules: &HashMap<String, EarningRule>,
1060 actuals: &HashMap<String, Actuals>,
1061 hours: i64,
1062 ) -> EvReport {
1063 let as_of = b.start + Duration::hours(hours);
1064 compute_ev(b, g, st, rules, actuals, as_of, &[]).unwrap()
1065 }
1066
1067 fn one(rule: EarningRule, status: DeliverableStatus, pct: Option<u8>) -> EvReport {
1068 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1069 let g = graph(vec![deliverable("a", 100.0, &[])]);
1070 let st = statuses(&[("a", status)]);
1071 let rules = HashMap::from([("a".to_string(), rule)]);
1072 let actuals = HashMap::from([(
1073 "a".to_string(),
1074 Actuals {
1075 earned_pct: pct,
1076 ..Actuals::default()
1077 },
1078 )]);
1079 report(&b, &g, &st, &rules, &actuals, 5)
1080 }
1081
1082 fn textbook() -> EvReport {
1084 let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 10.0, 20.0, 50.0)]);
1085 let g = graph(vec![
1086 deliverable("a", 50.0, &[]),
1087 deliverable("b", 50.0, &["a"]),
1088 ]);
1089 let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1090 let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1091 let actuals = HashMap::from([(
1092 "a".to_string(),
1093 Actuals {
1094 earned_pct: Some(80),
1095 actual_hours: Some(48.0),
1096 ..Actuals::default()
1097 },
1098 )]);
1099 report(&b, &g, &st, &rules, &actuals, 10)
1100 }
1101
1102 #[test]
1103 fn wall_clock_elapsed_is_plain_hours() {
1104 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 6, 6), None).unwrap();
1105 assert!(near(h, 30.0));
1106 }
1107
1108 #[test]
1109 fn elapsed_before_start_is_zero() {
1110 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 4, 0), None).unwrap();
1111 assert!(near(h, 0.0));
1112 }
1113
1114 #[test]
1115 fn calendar_skips_weekends() {
1116 let c = Calendar::default();
1118 let h = elapsed_hours(at(2026, 1, 9, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1119 assert!(near(h, 8.0));
1120 }
1121
1122 #[test]
1123 fn calendar_counts_partial_day() {
1124 let c = Calendar::default();
1126 let start = at(2026, 1, 5, 3);
1127 let h = elapsed_hours(start, start + Duration::minutes(150), Some(&c)).unwrap();
1128 assert!(near(h, 2.5));
1129 }
1130
1131 #[test]
1132 fn calendar_ignores_hours_after_the_working_window() {
1133 let c = Calendar::default();
1134 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 5, 20), Some(&c)).unwrap();
1135 assert!(near(h, 8.0));
1136 }
1137
1138 #[test]
1139 fn calendar_over_many_weeks_counts_whole_weeks() {
1140 let c = Calendar::default();
1142 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 3, 16, 0), Some(&c)).unwrap();
1143 assert!(near(h, 400.0));
1144 }
1145
1146 #[test]
1147 fn calendar_utc_offset_shifts_the_local_day() {
1148 let c = Calendar {
1150 utc_offset_minutes: 120,
1151 ..Calendar::default()
1152 };
1153 let h = elapsed_hours(at(2026, 1, 5, 22), at(2026, 1, 6, 0), Some(&c)).unwrap();
1154 assert!(near(h, 2.0));
1155 }
1156
1157 #[test]
1158 fn calendar_custom_workdays_count_only_listed_days() {
1159 let c = Calendar {
1160 workdays: vec!["sat".into()],
1161 ..Calendar::default()
1162 };
1163 let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1164 assert!(near(h, 8.0));
1165 }
1166
1167 fn invalid(c: Calendar) -> bool {
1168 matches!(c.validate(), Err(PlannerError::InvalidGraph { .. }))
1169 }
1170
1171 #[test]
1172 fn calendar_rejects_zero_hours_per_day() {
1173 assert!(invalid(Calendar {
1174 hours_per_day: 0.0,
1175 ..Calendar::default()
1176 }));
1177 }
1178
1179 #[test]
1180 fn calendar_rejects_more_than_24_hours_per_day() {
1181 assert!(invalid(Calendar {
1182 hours_per_day: 24.5,
1183 ..Calendar::default()
1184 }));
1185 }
1186
1187 #[test]
1188 fn calendar_rejects_nan_hours_per_day() {
1189 assert!(invalid(Calendar {
1190 hours_per_day: f32::NAN,
1191 ..Calendar::default()
1192 }));
1193 }
1194
1195 #[test]
1196 fn calendar_rejects_out_of_range_offset() {
1197 assert!(invalid(Calendar {
1198 utc_offset_minutes: 1441,
1199 ..Calendar::default()
1200 }));
1201 }
1202
1203 #[test]
1204 fn calendar_rejects_empty_workdays() {
1205 assert!(invalid(Calendar {
1206 workdays: vec![],
1207 ..Calendar::default()
1208 }));
1209 }
1210
1211 #[test]
1212 fn calendar_rejects_unknown_weekday_name() {
1213 assert!(invalid(Calendar {
1214 workdays: vec!["monday".into()],
1215 ..Calendar::default()
1216 }));
1217 }
1218
1219 #[test]
1220 fn calendar_accepts_a_24_hour_day() {
1221 assert!(
1222 Calendar {
1223 hours_per_day: 24.0,
1224 ..Calendar::default()
1225 }
1226 .validate()
1227 .is_ok()
1228 );
1229 }
1230
1231 #[test]
1232 fn pv_interpolates_linearly_inside_a_task() {
1233 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1234 let g = graph(vec![deliverable("a", 100.0, &[])]);
1235 let r = report(
1236 &b,
1237 &g,
1238 &HashMap::new(),
1239 &HashMap::new(),
1240 &HashMap::new(),
1241 15,
1242 );
1243 assert!(near(r.pv, 50.0));
1244 }
1245
1246 #[test]
1247 fn pv_is_zero_before_task_start() {
1248 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1249 let g = graph(vec![deliverable("a", 100.0, &[])]);
1250 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1251 assert!(near(r.pv, 0.0));
1252 }
1253
1254 #[test]
1255 fn pv_caps_at_budget_after_task_finish() {
1256 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1257 let g = graph(vec![deliverable("a", 100.0, &[])]);
1258 let r = report(
1259 &b,
1260 &g,
1261 &HashMap::new(),
1262 &HashMap::new(),
1263 &HashMap::new(),
1264 99,
1265 );
1266 assert!(near(r.pv, 100.0));
1267 }
1268
1269 #[test]
1270 fn pv_of_zero_length_task_is_zero_before_its_start() {
1271 let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1272 let g = graph(vec![deliverable("m", 5.0, &[])]);
1273 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 9);
1274 assert!(near(r.pv, 0.0));
1275 }
1276
1277 #[test]
1278 fn pv_of_zero_length_task_steps_to_budget_at_its_start() {
1279 let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1280 let g = graph(vec![deliverable("m", 5.0, &[])]);
1281 let r = report(
1282 &b,
1283 &g,
1284 &HashMap::new(),
1285 &HashMap::new(),
1286 &HashMap::new(),
1287 10,
1288 );
1289 assert!(near(r.pv, 5.0));
1290 }
1291
1292 #[test]
1293 fn zero_hundred_earns_nothing_while_in_progress() {
1294 let r = one(
1295 EarningRule::ZeroHundred,
1296 DeliverableStatus::InProgress,
1297 Some(90),
1298 );
1299 assert!(near(r.ev, 0.0));
1300 }
1301
1302 #[test]
1303 fn zero_hundred_earns_full_budget_when_complete() {
1304 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1305 assert!(near(r.ev, 100.0));
1306 }
1307
1308 #[test]
1309 fn default_rule_is_zero_hundred_when_unmapped() {
1310 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1311 let g = graph(vec![deliverable("a", 100.0, &[])]);
1312 let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1313 let r = report(&b, &g, &st, &HashMap::new(), &HashMap::new(), 5);
1314 assert!(near(r.ev, 0.0));
1315 }
1316
1317 #[test]
1318 fn fifty_fifty_earns_half_when_in_progress() {
1319 let r = one(EarningRule::FiftyFifty, DeliverableStatus::InProgress, None);
1320 assert!(near(r.ev, 50.0));
1321 }
1322
1323 #[test]
1324 fn fifty_fifty_earns_half_when_any_percent_reported() {
1325 let r = one(EarningRule::FiftyFifty, DeliverableStatus::Ready, Some(10));
1326 assert!(near(r.ev, 50.0));
1327 }
1328
1329 #[test]
1330 fn fifty_fifty_earns_full_when_complete() {
1331 let r = one(EarningRule::FiftyFifty, DeliverableStatus::Complete, None);
1332 assert!(near(r.ev, 100.0));
1333 }
1334
1335 #[test]
1336 fn weighted_earns_the_reported_percent() {
1337 let r = one(
1338 EarningRule::Weighted,
1339 DeliverableStatus::InProgress,
1340 Some(30),
1341 );
1342 assert!(near(r.ev, 30.0));
1343 }
1344
1345 #[test]
1346 fn weighted_earns_nothing_without_a_report() {
1347 let r = one(EarningRule::Weighted, DeliverableStatus::InProgress, None);
1348 assert!(near(r.ev, 0.0));
1349 }
1350
1351 #[test]
1352 fn weighted_ignores_reported_percent_once_complete() {
1353 let r = one(EarningRule::Weighted, DeliverableStatus::Complete, Some(40));
1354 assert!(near(r.ev, 100.0));
1355 }
1356
1357 #[test]
1358 fn failed_deliverable_earns_nothing_under_zero_hundred() {
1359 let r = one(
1360 EarningRule::ZeroHundred,
1361 DeliverableStatus::Failed { reason: "x".into() },
1362 None,
1363 );
1364 assert!(near(r.ev, 0.0));
1365 }
1366
1367 fn ac_for(actual: Option<f32>, leased: f32, rate: Option<f64>) -> f32 {
1368 let mut br = row("a", 0.0, 10.0, 100.0);
1369 br.cost_rate = rate.unwrap_or(1.0) as f32;
1370 let b = baseline(vec![br]);
1371 let g = graph(vec![deliverable("a", 100.0, &[])]);
1372 let actuals = HashMap::from([(
1373 "a".to_string(),
1374 Actuals {
1375 actual_hours: actual,
1376 leased_hours: leased,
1377 ..Actuals::default()
1378 },
1379 )]);
1380 report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5).ac
1381 }
1382
1383 #[test]
1384 fn ac_prefers_reported_hours_over_leased() {
1385 assert!(near(ac_for(Some(7.0), 3.0, None), 7.0));
1386 }
1387
1388 #[test]
1389 fn ac_falls_back_to_leased_hours() {
1390 assert!(near(ac_for(None, 3.0, None), 3.0));
1391 }
1392
1393 #[test]
1394 fn ac_is_scaled_by_cost_rate() {
1395 assert!(near(ac_for(Some(4.0), 0.0, Some(2.5)), 10.0));
1396 }
1397
1398 #[test]
1399 fn baseline_budget_is_effort_times_cost_rate() {
1400 let mut d = deliverable("a", 10.0, &[]);
1401 d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1402 let g = graph(vec![d]);
1403 let cpm = compute_cpm(&g).unwrap();
1404 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1405 assert!(near(b.bac, 30.0));
1406 }
1407
1408 #[test]
1409 fn baseline_defaults_cost_rate_to_one() {
1410 let g = graph(vec![deliverable("a", 10.0, &[])]);
1411 let cpm = compute_cpm(&g).unwrap();
1412 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1413 assert!(near(b.bac, 10.0));
1414 }
1415
1416 #[test]
1417 fn baseline_excludes_synthetic_endpoints() {
1418 let g = graph(vec![deliverable("a", 10.0, &[])]);
1419 let cpm = compute_cpm(&g).unwrap();
1420 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1421 assert_eq!(b.rows.len(), 1);
1422 }
1423
1424 #[test]
1425 fn baseline_freezes_cpm_start_and_finish() {
1426 let g = graph(vec![
1427 deliverable("a", 10.0, &[]),
1428 deliverable("b", 5.0, &["a"]),
1429 ]);
1430 let cpm = compute_cpm(&g).unwrap();
1431 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1432 let rb = b.rows.iter().find(|r| r.id == "b").unwrap();
1433 assert!(near(rb.es, 10.0) && near(rb.ef, 15.0));
1434 }
1435
1436 #[test]
1437 fn baseline_records_finish_hours() {
1438 let g = graph(vec![
1439 deliverable("a", 10.0, &[]),
1440 deliverable("b", 5.0, &["a"]),
1441 ]);
1442 let cpm = compute_cpm(&g).unwrap();
1443 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1444 assert!(near(b.finish_hours, 15.0));
1445 }
1446
1447 #[test]
1448 fn baseline_rejects_negative_cost_rate() {
1449 let mut d = deliverable("a", 10.0, &[]);
1450 d.metadata = serde_json::json!({ "cost_rate": -1.0 });
1451 let g = graph(vec![d]);
1452 let cpm = compute_cpm(&g).unwrap();
1453 let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1454 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1455 }
1456
1457 #[test]
1458 fn baseline_rejects_non_numeric_cost_rate() {
1459 let mut d = deliverable("a", 10.0, &[]);
1460 d.metadata = serde_json::json!({ "cost_rate": "free" });
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_invalid_calendar() {
1469 let g = graph(vec![deliverable("a", 10.0, &[])]);
1470 let cpm = compute_cpm(&g).unwrap();
1471 let c = Calendar {
1472 hours_per_day: 0.0,
1473 ..Calendar::default()
1474 };
1475 let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), Some(c), 1);
1476 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1477 }
1478
1479 #[test]
1480 fn budget_in_ev_report_is_baseline_budget() {
1481 let r = textbook();
1482 assert!(near(r.bac, 100.0));
1483 }
1484
1485 #[test]
1486 fn textbook_pv() {
1487 assert!(near(textbook().pv, 50.0));
1488 }
1489
1490 #[test]
1491 fn textbook_ev() {
1492 assert!(near(textbook().ev, 40.0));
1493 }
1494
1495 #[test]
1496 fn textbook_ac() {
1497 assert!(near(textbook().ac, 48.0));
1498 }
1499
1500 #[test]
1501 fn textbook_sv() {
1502 assert!(near(textbook().sv, -10.0));
1503 }
1504
1505 #[test]
1506 fn textbook_cv() {
1507 assert!(near(textbook().cv, -8.0));
1508 }
1509
1510 #[test]
1511 fn textbook_spi() {
1512 assert!(near(textbook().spi.unwrap(), 0.8));
1513 }
1514
1515 #[test]
1516 fn textbook_cpi() {
1517 assert!(near(textbook().cpi.unwrap(), 0.8333));
1518 }
1519
1520 #[test]
1521 fn textbook_eac() {
1522 assert!(near(textbook().eac.unwrap(), 120.0));
1523 }
1524
1525 #[test]
1526 fn textbook_etc() {
1527 assert!(near(textbook().etc.unwrap(), 72.0));
1528 }
1529
1530 #[test]
1531 fn textbook_vac() {
1532 assert!(near(textbook().vac.unwrap(), -20.0));
1533 }
1534
1535 #[test]
1536 fn textbook_tcpi() {
1537 assert!(near(textbook().tcpi.unwrap(), 60.0 / 52.0));
1538 }
1539
1540 #[test]
1541 fn textbook_has_no_undefined_fields() {
1542 assert!(textbook().undefined.is_empty());
1543 }
1544
1545 fn undefined_reason(r: &EvReport, field: &str) -> Option<String> {
1546 r.undefined
1547 .iter()
1548 .find(|u| u.field == field)
1549 .map(|u| u.reason.clone())
1550 }
1551
1552 #[test]
1553 fn spi_is_null_when_pv_is_zero() {
1554 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1555 let g = graph(vec![deliverable("a", 100.0, &[])]);
1556 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1557 assert!(r.spi.is_none());
1558 }
1559
1560 #[test]
1561 fn spi_zero_pv_reason_is_explained() {
1562 let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1563 let g = graph(vec![deliverable("a", 100.0, &[])]);
1564 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1565 assert_eq!(
1566 undefined_reason(&r, "spi").as_deref(),
1567 Some("PV is 0 before any work was planned")
1568 );
1569 }
1570
1571 #[test]
1572 fn cpi_is_null_when_ac_is_zero() {
1573 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1574 assert!(r.cpi.is_none());
1575 }
1576
1577 #[test]
1578 fn cpi_zero_ac_reason_is_explained() {
1579 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1580 assert_eq!(
1581 undefined_reason(&r, "cpi").as_deref(),
1582 Some("AC is 0, no cost recorded yet")
1583 );
1584 }
1585
1586 #[test]
1587 fn eac_is_null_when_cpi_is_null() {
1588 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1589 assert!(r.eac.is_none());
1590 }
1591
1592 #[test]
1593 fn dependents_of_eac_are_null_and_explained() {
1594 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1595 assert!(r.etc.is_none() && r.vac.is_none());
1596 assert!(undefined_reason(&r, "etc").is_some() && undefined_reason(&r, "vac").is_some());
1597 }
1598
1599 #[test]
1600 fn eac_is_null_when_cpi_is_zero() {
1601 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1603 let g = graph(vec![deliverable("a", 100.0, &[])]);
1604 let actuals = HashMap::from([(
1605 "a".to_string(),
1606 Actuals {
1607 actual_hours: Some(10.0),
1608 ..Actuals::default()
1609 },
1610 )]);
1611 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1612 assert!(r.eac.is_none());
1613 }
1614
1615 #[test]
1616 fn tcpi_is_null_when_bac_equals_ac() {
1617 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1618 let g = graph(vec![deliverable("a", 100.0, &[])]);
1619 let actuals = HashMap::from([(
1620 "a".to_string(),
1621 Actuals {
1622 actual_hours: Some(100.0),
1623 ..Actuals::default()
1624 },
1625 )]);
1626 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1627 assert!(r.tcpi.is_none());
1628 }
1629
1630 #[test]
1631 fn tcpi_zero_denominator_reason_is_explained() {
1632 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1633 let g = graph(vec![deliverable("a", 100.0, &[])]);
1634 let actuals = HashMap::from([(
1635 "a".to_string(),
1636 Actuals {
1637 actual_hours: Some(100.0),
1638 ..Actuals::default()
1639 },
1640 )]);
1641 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1642 assert_eq!(
1643 undefined_reason(&r, "tcpi").as_deref(),
1644 Some("BAC - AC is 0, no budget remains")
1645 );
1646 }
1647
1648 #[test]
1649 fn empty_baseline_yields_all_finite_values() {
1650 let b = baseline(vec![]);
1651 let g = graph(vec![]);
1652 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1653 assert!(r.pv.is_finite() && r.ev.is_finite() && r.ac.is_finite());
1654 }
1655
1656 #[test]
1657 fn rows_report_per_deliverable_ev() {
1658 let r = textbook();
1659 let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1660 assert!(near(a.ev, 40.0));
1661 }
1662
1663 #[test]
1664 fn rows_report_status() {
1665 let r = textbook();
1666 let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1667 assert_eq!(a.status, DeliverableStatus::InProgress);
1668 }
1669
1670 #[test]
1671 fn missing_status_defaults_to_pending() {
1672 let r = textbook();
1673 let b = r.rows.iter().find(|x| x.id == "b").unwrap();
1674 assert_eq!(b.status, DeliverableStatus::Pending);
1675 }
1676
1677 #[test]
1678 fn critical_float_consumed_is_forecast_overrun() {
1679 let mut b = baseline(vec![row("a", 0.0, 5.0, 12.0)]);
1681 b.finish_hours = 5.0;
1682 let g = graph(vec![deliverable("a", 12.0, &[])]);
1683 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1684 assert!(near(r.critical_float_consumed_hours, 7.0));
1685 }
1686
1687 #[test]
1688 fn critical_float_consumed_never_negative() {
1689 let mut b = baseline(vec![row("a", 0.0, 20.0, 12.0)]);
1690 b.finish_hours = 20.0;
1691 let g = graph(vec![deliverable("a", 12.0, &[])]);
1692 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1693 assert!(near(r.critical_float_consumed_hours, 0.0));
1694 }
1695
1696 fn snap(spi: f32, cpi: f32) -> EvSummary {
1697 EvSummary {
1698 baseline_number: 1,
1699 spi: Some(spi),
1700 cpi: Some(cpi),
1701 backfilled: false,
1702 }
1703 }
1704
1705 fn alerts_with(prev: &[EvSummary]) -> Vec<String> {
1706 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1707 let g = graph(vec![deliverable("a", 100.0, &[])]);
1708 let as_of = b.start + Duration::hours(5);
1709 compute_ev(
1710 &b,
1711 &g,
1712 &HashMap::new(),
1713 &HashMap::new(),
1714 &HashMap::new(),
1715 as_of,
1716 prev,
1717 )
1718 .unwrap()
1719 .alerts
1720 }
1721
1722 #[test]
1723 fn spi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1724 let a = alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0), snap(0.85, 1.0)]);
1725 assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
1726 }
1727
1728 #[test]
1729 fn alert_does_not_fire_on_a_single_low_snapshot() {
1730 assert!(alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0)]).is_empty());
1731 }
1732
1733 #[test]
1734 fn alert_does_not_fire_with_one_snapshot() {
1735 assert!(alerts_with(&[snap(0.5, 0.5)]).is_empty());
1736 }
1737
1738 #[test]
1739 fn cpi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1740 let a = alerts_with(&[snap(1.0, 0.7), snap(1.0, 0.89)]);
1741 assert_eq!(a, vec!["CPI_BELOW_0_9".to_string()]);
1742 }
1743
1744 #[test]
1745 fn alert_ignores_snapshots_with_undefined_metric() {
1746 let none = EvSummary {
1747 baseline_number: 1,
1748 spi: None,
1749 cpi: None,
1750 backfilled: false,
1751 };
1752 assert!(alerts_with(&[snap(0.5, 0.5), none]).is_empty());
1753 }
1754
1755 #[test]
1756 fn report_is_independent_of_map_insertion_order() {
1757 let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 0.0, 10.0, 50.0)]);
1758 let g = graph(vec![
1759 deliverable("a", 50.0, &[]),
1760 deliverable("b", 50.0, &[]),
1761 ]);
1762 let act = |h: f32| Actuals {
1763 actual_hours: Some(h),
1764 ..Actuals::default()
1765 };
1766 let fwd = HashMap::from([("a".to_string(), act(3.0)), ("b".to_string(), act(4.0))]);
1767 let mut rev = HashMap::new();
1768 rev.insert("b".to_string(), act(4.0));
1769 rev.insert("a".to_string(), act(3.0));
1770 let e = HashMap::new();
1771 assert_eq!(
1772 report(&b, &g, &e, &e2(), &fwd, 5),
1773 report(&b, &g, &e, &e2(), &rev, 5)
1774 );
1775 }
1776
1777 fn e2() -> HashMap<String, EarningRule> {
1778 HashMap::new()
1779 }
1780
1781 #[test]
1782 fn removed_deliverable_keeps_baselined_cost_rate() {
1783 let mut br = row("a", 0.0, 10.0, 100.0);
1784 br.cost_rate = 2.0;
1785 let b = baseline(vec![br]);
1786 let g = graph(vec![]);
1787 let actuals = HashMap::from([(
1788 "a".to_string(),
1789 Actuals {
1790 actual_hours: Some(5.0),
1791 ..Actuals::default()
1792 },
1793 )]);
1794 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1795 assert!(near(r.ac, 10.0));
1796 }
1797
1798 #[test]
1799 fn rate_edit_after_baseline_does_not_change_ac() {
1800 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1801 let mut d = deliverable("a", 100.0, &[]);
1802 d.metadata = serde_json::json!({ "cost_rate": 9.0 });
1803 let actuals = HashMap::from([(
1804 "a".to_string(),
1805 Actuals {
1806 actual_hours: Some(5.0),
1807 ..Actuals::default()
1808 },
1809 )]);
1810 let r = report(
1811 &b,
1812 &graph(vec![d]),
1813 &HashMap::new(),
1814 &HashMap::new(),
1815 &actuals,
1816 5,
1817 );
1818 assert!(near(r.ac, 5.0));
1819 }
1820
1821 #[test]
1822 fn invalid_rate_added_after_baseline_does_not_fail_compute() {
1823 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1824 let mut d = deliverable("a", 100.0, &[]);
1825 d.metadata = serde_json::json!({ "cost_rate": -4.0 });
1826 let r = compute_ev(
1827 &b,
1828 &graph(vec![d]),
1829 &HashMap::new(),
1830 &HashMap::new(),
1831 &HashMap::new(),
1832 b.start,
1833 &[],
1834 );
1835 assert!(r.is_ok());
1836 }
1837
1838 #[test]
1839 fn baseline_captures_cost_rate_per_row() {
1840 let mut d = deliverable("a", 10.0, &[]);
1841 d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1842 let g = graph(vec![d]);
1843 let cpm = compute_cpm(&g).unwrap();
1844 let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1845 assert!(near(b.rows[0].cost_rate, 3.0));
1846 }
1847
1848 #[test]
1849 fn deliverable_added_after_baseline_is_excluded_and_listed() {
1850 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1851 let g = graph(vec![
1852 deliverable("z", 40.0, &[]),
1853 deliverable("a", 100.0, &[]),
1854 deliverable("m", 40.0, &[]),
1855 ]);
1856 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1857 assert_eq!(
1858 r.excluded_unbaselined,
1859 vec!["m".to_string(), "z".to_string()]
1860 );
1861 }
1862
1863 #[test]
1864 fn unbaselined_deliverable_adds_nothing_to_bac() {
1865 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1866 let g = graph(vec![
1867 deliverable("a", 100.0, &[]),
1868 deliverable("z", 40.0, &[]),
1869 ]);
1870 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1871 assert!(near(r.bac, 100.0));
1872 }
1873
1874 #[test]
1875 fn negative_reported_hours_fall_back_to_leased() {
1876 assert!(near(ac_for(Some(-2.0), 3.0, None), 3.0));
1877 }
1878
1879 #[test]
1880 fn non_finite_reported_hours_fall_back_to_leased() {
1881 assert!(near(ac_for(Some(f32::NAN), 3.0, None), 3.0));
1882 }
1883
1884 #[test]
1885 fn as_of_before_start_gives_zero_pv() {
1886 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1887 let g = graph(vec![deliverable("a", 100.0, &[])]);
1888 let r = report(
1889 &b,
1890 &g,
1891 &HashMap::new(),
1892 &HashMap::new(),
1893 &HashMap::new(),
1894 -5,
1895 );
1896 assert!(near(r.pv, 0.0));
1897 }
1898
1899 #[test]
1900 fn as_of_before_start_leaves_spi_null_with_reason() {
1901 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1902 let g = graph(vec![deliverable("a", 100.0, &[])]);
1903 let r = report(
1904 &b,
1905 &g,
1906 &HashMap::new(),
1907 &HashMap::new(),
1908 &HashMap::new(),
1909 -5,
1910 );
1911 assert!(r.spi.is_none() && undefined_reason(&r, "spi").is_some());
1912 }
1913
1914 #[test]
1915 fn pv_is_flat_over_a_weekend_on_a_working_calendar() {
1916 let mut b = baseline(vec![row("a", 0.0, 80.0, 100.0)]);
1918 b.calendar = Some(Calendar::default());
1919 let g = graph(vec![deliverable("a", 100.0, &[])]);
1920 let at_fri_end = compute_ev(
1921 &b,
1922 &g,
1923 &HashMap::new(),
1924 &HashMap::new(),
1925 &HashMap::new(),
1926 at(2026, 1, 9, 12),
1927 &[],
1928 )
1929 .unwrap();
1930 let mon = compute_ev(
1931 &b,
1932 &g,
1933 &HashMap::new(),
1934 &HashMap::new(),
1935 &HashMap::new(),
1936 at(2026, 1, 12, 0),
1937 &[],
1938 )
1939 .unwrap();
1940 assert!(near(at_fri_end.pv, mon.pv));
1941 }
1942
1943 #[test]
1944 fn eac_reason_when_cpi_is_zero_is_explained() {
1945 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1946 let g = graph(vec![deliverable("a", 100.0, &[])]);
1947 let actuals = HashMap::from([(
1948 "a".to_string(),
1949 Actuals {
1950 actual_hours: Some(10.0),
1951 ..Actuals::default()
1952 },
1953 )]);
1954 let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1955 assert_eq!(
1956 undefined_reason(&r, "eac").as_deref(),
1957 Some("CPI is 0, nothing earned for the cost spent")
1958 );
1959 }
1960
1961 #[test]
1962 fn tcpi_is_negative_when_ac_exceeds_bac() {
1963 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1964 let g = graph(vec![deliverable("a", 100.0, &[])]);
1965 let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1966 let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1967 let actuals = HashMap::from([(
1968 "a".to_string(),
1969 Actuals {
1970 earned_pct: Some(50),
1971 actual_hours: Some(150.0),
1972 ..Actuals::default()
1973 },
1974 )]);
1975 let r = report(&b, &g, &st, &rules, &actuals, 5);
1976 assert!(near(r.tcpi.unwrap(), -1.0));
1977 }
1978
1979 #[test]
1980 fn f32_overflow_in_a_ratio_becomes_undefined() {
1981 let b = baseline(vec![
1982 row("a", 0.0, 10.0, 3.0e38),
1983 row("b", 0.0, 10.0, 3.0e38),
1984 ]);
1985 let g = graph(vec![deliverable("a", 1.0, &[]), deliverable("b", 1.0, &[])]);
1986 let st = statuses(&[("a", DeliverableStatus::Complete)]);
1987 let actuals = HashMap::from([(
1988 "a".to_string(),
1989 Actuals {
1990 actual_hours: Some(1.0e-30),
1991 ..Actuals::default()
1992 },
1993 )]);
1994 let r = report(&b, &g, &st, &HashMap::new(), &actuals, 5);
1995 assert!(r.cpi.is_none() && undefined_reason(&r, "cpi").is_some());
1996 }
1997
1998 #[test]
1999 fn alert_needs_both_of_the_two_previous_snapshots() {
2000 assert!(alerts_with(&[snap(0.5, 1.0), snap(0.5, 1.0), snap(1.0, 1.0)]).is_empty());
2002 }
2003
2004 #[test]
2005 fn elapsed_matches_naive_minute_loop_over_random_spans() {
2006 let mut seed: u64 = 0x5eed_1234_abcd_ef01;
2007 let mut next = move |n: u64| {
2008 seed = seed
2009 .wrapping_mul(6364136223846793005)
2010 .wrapping_add(1442695040888963407);
2011 (seed >> 33) % n
2012 };
2013 let names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
2014 let mut mismatches = Vec::new();
2015 for case in 0..60 {
2016 let start = at(2026, 1, 3, 0) + Duration::minutes(next(60 * 24 * 14) as i64);
2017 let span = Duration::minutes(next(60 * 24 * 40) as i64 + 1);
2018 let off = next(2881) as i32 - 1440;
2019 let n_days = 1 + next(7) as usize;
2020 let first = next(7) as usize;
2021 let workdays: Vec<String> = (0..n_days)
2022 .map(|i| names[(first + i) % 7].to_string())
2023 .collect();
2024 let hpd = [8.0_f32, 6.5, 24.0][next(3) as usize];
2025 let c = Calendar {
2026 hours_per_day: hpd,
2027 workdays: workdays.clone(),
2028 utc_offset_minutes: off,
2029 };
2030 let got = elapsed_hours(start, start + span, Some(&c)).unwrap();
2031 let mut naive = 0u64;
2032 let window = (f64::from(hpd) * 60.0) as i64;
2033 let mut m = start;
2034 let end = start + span;
2035 while m < end {
2036 let local = m + Duration::minutes(i64::from(off));
2037 let name = names[local.weekday().num_days_from_monday() as usize];
2038 let mins = i64::from(local.hour()) * 60 + i64::from(local.minute());
2039 if workdays.iter().any(|w| w == name) && mins < window {
2040 naive += 1;
2041 }
2042 m += Duration::minutes(1);
2043 }
2044 let want = naive as f32 / 60.0;
2045 if (got - want).abs() > 1e-2 {
2046 mismatches.push((case, got, want));
2047 }
2048 }
2049 assert!(mismatches.is_empty(), "{mismatches:?}");
2050 }
2051
2052 #[test]
2055 fn trend_alerts_fire_spi_when_two_latest_are_low() {
2056 let a = trend_alerts(&[snap(0.8, 1.0), snap(0.85, 1.0)], 1);
2057 assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
2058 }
2059
2060 #[test]
2061 fn baseline_rejects_budget_total_beyond_f32() {
2062 let heavy = |id: &str| {
2063 let mut d = deliverable(id, 1.0, &[]);
2064 d.metadata = serde_json::json!({ "cost_rate": 3.0e38 });
2065 d
2066 };
2067 let g = graph(vec![heavy("a"), heavy("b")]);
2068 let cpm = compute_cpm(&g).unwrap();
2069 let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
2070 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
2071 }
2072
2073 #[test]
2074 fn ev_rejects_actual_cost_beyond_f32() {
2075 let mut br = row("a", 0.0, 10.0, 100.0);
2076 br.cost_rate = 3.0e38;
2077 let b = baseline(vec![br]);
2078 let g = graph(vec![deliverable("a", 100.0, &[])]);
2079 let actuals = HashMap::from([(
2080 "a".to_string(),
2081 Actuals {
2082 actual_hours: Some(1.0e6),
2083 ..Actuals::default()
2084 },
2085 )]);
2086 let as_of = b.start + Duration::hours(5);
2087 let e = compute_ev(
2088 &b,
2089 &g,
2090 &HashMap::new(),
2091 &HashMap::new(),
2092 &actuals,
2093 as_of,
2094 &[],
2095 );
2096 assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
2097 }
2098
2099 #[test]
2100 fn calendar_rejects_unknown_fields() {
2101 let c = serde_json::from_value::<Calendar>(serde_json::json!({ "hours": 8 }));
2102 assert!(c.is_err());
2103 }
2104
2105 fn summary_of(r: &EvReport) -> SnapshotSummary {
2106 SnapshotSummary::from_report(r, r.as_of)
2107 }
2108
2109 #[test]
2110 fn snapshot_summary_carries_report_spi() {
2111 let r = textbook();
2112 assert_eq!(summary_of(&r).spi, r.spi);
2113 }
2114
2115 #[test]
2116 fn snapshot_markdown_starts_with_header_row() {
2117 let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2118 assert!(md.starts_with("| date | PV | EV | AC | SPI | CPI | EAC |\n"));
2119 }
2120
2121 #[test]
2122 fn snapshot_markdown_renders_one_row_per_snapshot() {
2123 let s = summary_of(&textbook());
2124 let md = render_snapshots_markdown(&[s.clone(), s]);
2125 assert_eq!(md.lines().count(), 4);
2126 }
2127
2128 #[test]
2129 fn snapshot_markdown_row_shows_textbook_values() {
2130 let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2131 assert_eq!(
2132 md.lines().nth(2),
2133 Some("| 2026-01-05T10:00Z | 50.00 | 40.00 | 48.00 | 0.80 | 0.83 | 120.00 |")
2134 );
2135 }
2136
2137 #[test]
2138 fn snapshot_markdown_renders_undefined_ratio_as_na() {
2139 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2140 let md = render_snapshots_markdown(&[summary_of(&r)]);
2141 assert!(
2142 md.lines()
2143 .nth(2)
2144 .is_some_and(|l| l.ends_with("| n/a | n/a |"))
2145 );
2146 }
2147
2148 #[test]
2149 fn alerts_ignore_a_backfilled_new_reading() {
2150 let mut backfilled = snap(0.5, 1.0);
2151 backfilled.backfilled = true;
2152 assert!(trend_alerts(&[snap(0.5, 1.0), backfilled], 1).is_empty());
2153 }
2154
2155 #[test]
2156 fn snapshot_more_than_an_hour_behind_its_taken_at_is_backfilled() {
2157 let r = textbook();
2158 assert!(SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(61)).backfilled);
2159 }
2160
2161 #[test]
2162 fn snapshot_within_an_hour_of_its_taken_at_is_not_backfilled() {
2163 let r = textbook();
2164 assert!(!SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(60)).backfilled);
2165 }
2166
2167 #[test]
2168 fn removed_row_earns_its_frozen_percent() {
2169 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2170 let actuals = HashMap::from([(
2171 "a".to_string(),
2172 Actuals {
2173 frozen_pct: Some(40),
2174 ..Actuals::default()
2175 },
2176 )]);
2177 let r = report(&b, &graph(vec![]), &HashMap::new(), &e2(), &actuals, 5);
2178 assert!(near(r.ev, 40.0));
2179 }
2180
2181 #[test]
2182 fn removed_row_without_frozen_percent_earns_nothing() {
2183 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2184 let st = statuses(&[("a", DeliverableStatus::Complete)]);
2185 let r = report(&b, &graph(vec![]), &st, &e2(), &HashMap::new(), 5);
2186 assert!(near(r.ev, 0.0));
2187 }
2188
2189 #[test]
2190 fn unbaselined_actuals_count_in_ac_at_rate_one() {
2191 let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2192 let g = graph(vec![
2193 deliverable("a", 100.0, &[]),
2194 deliverable("n", 5.0, &[]),
2195 ]);
2196 let actuals = HashMap::from([(
2197 "n".to_string(),
2198 Actuals {
2199 actual_hours: Some(3.0),
2200 ..Actuals::default()
2201 },
2202 )]);
2203 let r = report(&b, &g, &HashMap::new(), &e2(), &actuals, 5);
2204 assert!(near(r.ac, 3.0));
2205 }
2206
2207 #[test]
2208 fn frozen_pct_of_a_complete_deliverable_is_100() {
2209 let pct = frozen_pct(EarningRule::ZeroHundred, &DeliverableStatus::Complete, None);
2210 assert_eq!(pct, 100);
2211 }
2212
2213 #[test]
2214 fn alerts_ignore_snapshots_from_previous_baseline() {
2215 assert!(trend_alerts(&[snap(0.5, 0.5), snap(0.5, 0.5)], 2).is_empty());
2216 }
2217
2218 #[test]
2219 fn alerts_use_the_two_latest_snapshots_of_the_current_baseline() {
2220 let mut current = snap(0.5, 1.0);
2221 current.baseline_number = 2;
2222 let a = trend_alerts(&[current.clone(), snap(1.0, 1.0), current], 2);
2223 assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
2224 }
2225
2226 #[test]
2227 fn snapshot_summary_explains_undefined_ratios() {
2228 let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2229 assert_eq!(summary_of(&r).undefined, r.undefined);
2230 }
2231
2232 #[test]
2233 fn summary_without_undefined_field_decodes() {
2234 let mut v = serde_json::to_value(summary_of(&textbook())).unwrap();
2235 v.as_object_mut().unwrap().remove("undefined");
2236 assert!(serde_json::from_value::<SnapshotSummary>(v).is_ok());
2237 }
2238}