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