use core::time::Duration;
use crate::advise::human;
use crate::model::{Mutant, Outcome};
use crate::report::quantity;
const KILLED_SHARE: f64 = 0.60;
const STALL_SHARE: f64 = 0.05;
const STALL_PERCENT_HIGH: u32 = 15;
fn stall_share_high() -> f64 {
f64::from(STALL_PERCENT_HIGH) / 100.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Workload {
pub suite: Duration,
pub budget: Duration,
pub single: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Estimate {
pub live: usize,
pub withdrawn: usize,
pub build: Duration,
pub baseline: Duration,
pub mutants: Duration,
pub settled: Duration,
pub stalling: Duration,
pub jobs: usize,
pub worst: Duration,
}
impl Estimate {
#[must_use]
pub fn low(&self) -> Duration {
self.build + self.baseline + self.settled
}
#[must_use]
pub fn high(&self) -> Duration {
(self.build + self.baseline + self.stalling).min(self.worst_case())
}
#[must_use]
pub fn worst_case(&self) -> Duration {
self.build + self.baseline + self.worst
}
}
#[must_use]
pub fn project(mutants: &[Mutant], work: Workload, baseline: Duration, build: Duration, jobs: usize) -> Estimate {
let live = mutants
.iter()
.filter(|mutant| mutant.ordinal > 0 && mutant.outcome == Outcome::Pending)
.count();
let withdrawn = mutants.iter().filter(|mutant| mutant.outcome == Outcome::CompileError).count();
let lanes = u32::try_from(jobs.max(1)).unwrap_or(1);
Estimate {
live,
withdrawn,
build,
baseline,
mutants: spend(work, lanes, STALL_SHARE),
settled: spend(work, lanes, 0.0),
stalling: spend(work, lanes, stall_share_high()),
jobs,
worst: work.budget.saturating_mul(1 + crate::exec::CONFIRM_FACTOR) / lanes,
}
}
fn spend(work: Workload, lanes: u32, stalling: f64) -> Duration {
let judged = work.suite.mul_f64(KILLED_SHARE * (1.0 - stalling));
let hung = (work.suite + work.single.saturating_mul(1 + crate::exec::CONFIRM_FACTOR)).mul_f64(stalling);
(judged + hung) / lanes
}
#[must_use]
pub fn render(estimate: &Estimate) -> String {
format!(
"{} if none hang, {} if {}% do, for {} at {}; {} worst case for test time, before per-mutant overhead",
human(estimate.low()),
human(estimate.high()),
STALL_PERCENT_HIGH,
quantity(estimate.live, "mutant"),
quantity(estimate.jobs, "job"),
human(estimate.worst_case())
)
}
#[cfg(test)]
mod tests {
use camino::Utf8PathBuf;
use super::*;
use crate::fixtures;
fn mutant(ordinal: u32, outcome: Outcome) -> Mutant {
Mutant {
id: format!("m{ordinal}{outcome}").into(),
ordinal,
package: ("p".to_owned()).into(),
file: (Utf8PathBuf::from("a.rs")).into(),
outcome,
..fixtures::mutant()
}
}
fn population() -> Vec<Mutant> {
let mut mutants: Vec<Mutant> = (1..=100).map(|index| mutant(index, Outcome::Pending)).collect();
mutants.push(mutant(0, Outcome::Ignored));
mutants.push(mutant(101, Outcome::CompileError));
mutants
}
fn work(secs: u64) -> Workload {
Workload {
suite: Duration::from_secs(secs),
budget: Duration::from_secs(secs * 10),
single: Duration::from_secs(secs * 5),
}
}
fn suite_only(secs: u64) -> Workload {
Workload {
suite: Duration::from_secs(secs),
..Workload::default()
}
}
#[test]
fn only_mutants_that_would_run_are_counted() {
let estimate = project(&population(), work(100), Duration::ZERO, Duration::from_secs(5), 1);
assert_eq!(estimate.live, 100);
assert_eq!(estimate.withdrawn, 1);
}
#[test]
fn parallelism_divides_the_projection() {
let one = project(&population(), work(1000), Duration::ZERO, Duration::from_secs(50), 1);
let eight = project(&population(), work(1000), Duration::ZERO, Duration::from_secs(50), 8);
assert_eq!(one.mutants / 8, eight.mutants);
assert_eq!(one.worst / 8, eight.worst);
}
#[test]
fn zero_jobs_does_not_divide_by_zero() {
let estimate = project(&population(), work(1000), Duration::ZERO, Duration::from_secs(50), 0);
assert!(estimate.mutants > Duration::ZERO);
}
#[test]
fn only_the_binaries_a_mutant_reaches_are_charged_for_it() {
let narrow = project(&population(), suite_only(100), Duration::ZERO, Duration::ZERO, 1);
let wide = project(&population(), suite_only(1000), Duration::ZERO, Duration::ZERO, 1);
assert_eq!(narrow.mutants * 10, wide.mutants);
}
#[test]
fn the_error_bar_brackets_the_estimate() {
let estimate = project(&population(), work(1000), Duration::from_secs(3), Duration::from_secs(50), 4);
assert!(estimate.low() < estimate.high());
assert!(estimate.build + estimate.baseline <= estimate.low());
}
#[test]
fn the_error_bar_never_widens_the_part_that_was_measured() {
let estimate = project(&[], Workload::default(), Duration::ZERO, Duration::from_secs(30), 4);
assert_eq!(estimate.low(), Duration::from_secs(30));
assert_eq!(estimate.high(), Duration::from_secs(30));
}
#[test]
fn the_worst_case_exceeds_the_estimate() {
let estimate = project(&population(), work(1000), Duration::from_secs(3), Duration::from_secs(50), 4);
assert!(estimate.worst_case() > estimate.high());
}
#[test]
fn the_worst_case_pays_for_confirming_every_timeout() {
let load = work(1000);
let estimate = project(&population(), load, Duration::ZERO, Duration::ZERO, 1);
assert_eq!(estimate.worst_case(), load.budget.saturating_mul(1 + crate::exec::CONFIRM_FACTOR));
}
#[test]
fn the_projected_range_never_reaches_past_the_ceiling() {
let load = Workload {
suite: Duration::from_secs(1000),
..work(1)
};
let estimate = project(&population(), load, Duration::ZERO, Duration::ZERO, 1);
assert_eq!(estimate.high(), estimate.worst_case());
}
#[test]
fn the_rendering_is_one_line_carrying_the_range_the_population_and_the_worst_case() {
let estimate = project(&population(), work(1000), Duration::from_secs(3), Duration::from_secs(50), 4);
let rendered = render(&estimate);
assert_eq!(rendered.lines().count(), 1, "{rendered}");
assert!(rendered.contains("100 mutants"), "{rendered}");
assert!(rendered.contains("4 jobs"), "{rendered}");
assert!(rendered.contains("worst case"), "{rendered}");
}
#[test]
fn the_rendering_says_what_widens_the_range() {
let estimate = project(&population(), work(1000), Duration::from_secs(3), Duration::from_secs(50), 4);
let rendered = render(&estimate);
assert!(rendered.contains("if none hang"), "{rendered}");
assert!(rendered.contains("if 15% do"), "{rendered}");
}
#[test]
fn the_rendering_says_what_the_ceiling_leaves_out() {
let estimate = project(&population(), work(1000), Duration::from_secs(3), Duration::from_secs(50), 4);
let rendered = render(&estimate);
assert!(rendered.contains("worst case for test time"), "{rendered}");
assert!(rendered.contains("before per-mutant overhead"), "{rendered}");
}
#[test]
fn hanging_mutants_cost_more_than_mutants_that_are_judged() {
let load = Workload {
suite: Duration::from_secs(10),
budget: Duration::from_secs(4000),
single: Duration::from_secs(2000),
};
let estimate = project(&population(), load, Duration::ZERO, Duration::ZERO, 1);
assert!(estimate.stalling > estimate.settled.saturating_mul(100), "{estimate:?}");
assert!(estimate.mutants > estimate.settled, "{estimate:?}");
}
#[test]
fn a_hang_is_charged_for_one_binary_rather_than_all_of_them() {
let all = Workload {
suite: Duration::ZERO,
budget: Duration::from_secs(400),
single: Duration::from_secs(400),
};
let one = Workload {
single: Duration::from_secs(100),
..all
};
let costly = project(&population(), all, Duration::ZERO, Duration::ZERO, 1);
let cheaper = project(&population(), one, Duration::ZERO, Duration::ZERO, 1);
assert_eq!(cheaper.stalling * 4, costly.stalling);
}
}