initial commit

This commit is contained in:
swaits committed 2024-08-14 22:07:27 -06:00
commit ca1044fcbb
15 files changed
+2310

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
pub mod schedule;
pub mod schedule_loader;
pub mod simulation;
pub mod task;
+203
View File
@@ -0,0 +1,203 @@
use mcps::{schedule::Schedule, simulation::run_multiple_simulations};
use clap::{Arg, Command};
use std::time::Duration;
const AFTER_HELP_TEXT: &str = "\
Examples of supported file formats:
YAML Format:
num_workers: 5
estimate_confidence: 0.80
tasks:
- id: DesignPhase
min_time: 1.5 # 1.5 days
max_time: 3.5 # 3.5 days
dependencies: []
- id: ImplementationPhase
min_time: 2.25 # 2.25 days
max_time: 4.75 # 4.75 days
dependencies: [DesignPhase]
JSON Format:
{
\"num_workers\": 5,
\"estimate_confidence\": 0.80,
\"tasks\": [
{
\"id\": \"DesignPhase\",
\"min_time\": 1.5,
\"max_time\": 3.5,
\"dependencies\": []
},
{
\"id\": \"ImplementationPhase\",
\"min_time\": 2.25,
\"max_time\": 4.75,
\"dependencies\": [\"DesignPhase\"]
}
]
}
Note: `estimate_confidence` is the confidence (0.0,1.0) that the
actual values fall in the task estimate ranges. For example,
0.8 means you believe that the actual time it will take to
complete a task falls in your [min_time,max_time] estimate
range for that task 80% of the time.";
fn main() -> Result<(), Box<dyn std::error::Error>> {
let matches = Command::new("Monte Carlo Project Scheduler")
.version("1.0")
.author("Stephen Waits <steve@waits.net>")
.about("Runs Monte Carlo simulations on project schedules")
.arg(
Arg::new("filename")
.help("Path to the schedule file (.yaml or .json).")
.required(true)
.index(1),
)
.arg(
Arg::new("iterations")
.short('i')
.long("iterations")
.help("Number of iterations to run")
.default_value("50000"),
)
.arg(
Arg::new("workers")
.short('w')
.long("workers")
.help("Override number of workers specified in schedule file")
.value_name("num_workers"),
)
.after_help(AFTER_HELP_TEXT)
.get_matches();
let schedule_path = matches.get_one::<String>("filename").unwrap();
let num_simulations: usize = matches.get_one::<String>("iterations").unwrap().parse()?;
// Ensure iterations is >= 100
if num_simulations < 100 {
return Err("Iterations must be at least 100.".into());
}
// Load the schedule
let mut schedule = Schedule::from_file(schedule_path)?;
// Check if workers are overridden by command-line argument
if let Some(workers_str) = matches.get_one::<String>("workers") {
let workers: usize = workers_str.parse()?;
if workers < 1 {
return Err("Invalid number of workers, must be 1 or more".into());
}
schedule.num_workers = workers;
}
// Monte Carlo simulation
let (project_durations, effort_times) = run_multiple_simulations(&schedule, num_simulations);
// Results output
print_ascii_cdf(
&project_durations,
"Project Completion Time (by probability)",
"Duration",
);
println!();
print_ascii_cdf(
&effort_times,
"Total Work Effort (by probability)",
"Effort",
);
Ok(())
}
fn print_ascii_cdf(data: &[Duration], title: &str, units: &str) {
let mut sorted_data = data.to_vec();
sorted_data.sort_unstable();
let min = sorted_data[0];
let max = *sorted_data.last().unwrap();
let width = 60; // Total width of the field
// Calculate padding
let padding = (width - title.len()) / 2;
// Create the centered title with padding on both sides
let centered_title = format!(
"{:padding_left$}{}{:padding_right$}",
"",
title,
"",
padding_left = padding,
padding_right = width - padding - title.len()
);
println!("────┬────────────────────────────────────────────────────────────┬──────────");
println!("%ile│{}│{:>10}", centered_title, units);
println!("────┼────────────────────────────────────────────────────────────┼──────────");
let bar_width = 60;
for i in 0..=20 {
let percentile = 100 - i * 5;
let lower_percentile = if i == 20 {
0.0000000001
} else {
(95 - i * 5) as f64 / 100.0
};
let upper_percentile = if i == 0 {
0.9999999999
} else {
(100 - i * 5) as f64 / 100.0
};
let lower_index = (lower_percentile * (sorted_data.len() - 1) as f64).round() as usize;
let upper_index = (upper_percentile * (sorted_data.len() - 1) as f64).round() as usize;
let mid_duration = (sorted_data[lower_index] + sorted_data[upper_index]) / 2;
let days = mid_duration.as_secs_f64() / 86400.0;
let normalized_position = (days - min.as_secs_f64() / 86400.0)
/ (max.as_secs_f64() / 86400.0 - min.as_secs_f64() / 86400.0);
let bar_position = (normalized_position * bar_width as f64).round() as usize;
let (fg, bg) = if i % 2 == 0 {
('░', '▓')
} else {
('▒', '█')
};
let color_code = match percentile {
0..=50 => "\x1b[31m", // Red
55..=75 => "\x1b[33m", // Orange
80..=90 => "\x1b[32m", // Green
95..=95 => "\x1b[33m", // Orange
100..=100 => "\x1b[31m", // Red
_ => "\x1b[0m", // Default (shouldn't happen)
};
let reset_code = "\x1b[0m";
let bar_with_divider: String = (0..bar_width)
.map(|j| match j {
_ if j == bar_position => '▮',
_ if j < bar_position => fg,
_ => bg,
})
.collect();
println!(
"{}{:>4}{}│{}{}{}│{}{:5.0} days{}",
color_code,
format!("p{}", percentile),
reset_code,
color_code,
bar_with_divider,
reset_code,
color_code,
days,
reset_code
);
}
println!("────┴────────────────────────────────────────────────────────────┴──────────");
}
+221
View File
@@ -0,0 +1,221 @@
use crate::task::Task;
use std::collections::{HashMap, HashSet};
use std::time::Duration;
#[derive(Debug)]
pub struct Schedule {
pub tasks: Vec<Task>,
pub num_workers: usize,
pub estimate_confidence: f64,
}
impl Schedule {
pub fn new(
tasks: Vec<Task>,
num_workers: usize,
estimate_confidence: f64,
) -> Result<Self, String> {
let schedule = Schedule {
tasks,
num_workers,
estimate_confidence,
};
schedule.validate()?;
Ok(schedule)
}
pub fn validate(&self) -> Result<(), String> {
if self.tasks.is_empty() {
return Err("Empty task list".to_string());
}
if self.num_workers == 0 {
return Err("Invalid number of workers (must be 1 or more)".to_string());
}
let all_task_ids: HashSet<&String> = self.tasks.iter().map(|t| &t.id).collect();
for task in &self.tasks {
if task.min_time > task.max_time {
return Err(format!(
"Minimum duration greater than maximum for task {}",
task.id
));
}
if task.min_time <= Duration::from_secs(0) || task.max_time <= Duration::from_secs(0) {
return Err(format!("Invalid task duration for task {}", task.id));
}
for dep in &task.dependencies {
if !all_task_ids.contains(dep) {
return Err(format!("Missing dependency {} for task {}", dep, task.id));
}
}
}
self.check_cyclic_dependencies()
}
fn check_cyclic_dependencies(&self) -> Result<(), String> {
let mut visited = HashSet::new();
let mut stack = HashSet::new();
let task_map: HashMap<_, _> = self.tasks.iter().map(|t| (&t.id, t)).collect();
for task in &self.tasks {
Self::dfs(&task.id, &task_map, &mut visited, &mut stack)?;
}
Ok(())
}
fn dfs<'a>(
task_id: &'a str,
task_map: &'a HashMap<&'a String, &'a Task>,
visited: &mut HashSet<&'a str>,
stack: &mut HashSet<&'a str>,
) -> Result<(), String> {
if stack.contains(task_id) {
return Err(format!(
"Cyclic dependency detected involving task {}",
task_id
));
}
if visited.contains(task_id) {
return Ok(());
}
visited.insert(task_id);
stack.insert(task_id);
if let Some(task) = task_map.get(&task_id.to_string()) {
for dep in &task.dependencies {
Self::dfs(dep, task_map, visited, stack)?;
}
}
stack.remove(task_id);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_task(id: &str, min: u64, max: u64, deps: Vec<&str>) -> Task {
Task {
id: id.to_string(),
dependencies: deps.into_iter().map(String::from).collect(),
min_time: Duration::from_secs(min),
max_time: Duration::from_secs(max),
}
}
#[test]
fn test_valid_schedule() {
let tasks = vec![
create_task("A", 1, 3, vec![]),
create_task("B", 2, 4, vec!["A"]),
create_task("C", 3, 5, vec!["A"]),
];
let schedule = Schedule::new(tasks, 2, 0.8);
assert!(schedule.is_ok());
}
#[test]
fn test_empty_task_list() {
let schedule = Schedule::new(vec![], 2, 0.8);
assert!(schedule.is_err());
assert_eq!(schedule.unwrap_err(), "Empty task list");
}
#[test]
fn test_invalid_task_duration() {
let tasks = vec![create_task("A", 3, 1, vec![])];
let schedule = Schedule::new(tasks, 2, 0.8);
assert!(schedule.is_err());
assert_eq!(
schedule.unwrap_err(),
"Minimum duration greater than maximum for task A"
);
}
#[test]
fn test_zero_duration() {
let tasks = vec![create_task("A", 0, 1, vec![])];
let schedule = Schedule::new(tasks, 2, 0.8);
assert!(schedule.is_err());
assert_eq!(schedule.unwrap_err(), "Invalid task duration for task A");
}
#[test]
fn test_missing_dependency() {
let tasks = vec![
create_task("A", 1, 3, vec![]),
create_task("B", 2, 4, vec!["C"]),
];
let schedule = Schedule::new(tasks, 2, 0.8);
assert!(schedule.is_err());
assert_eq!(schedule.unwrap_err(), "Missing dependency C for task B");
}
#[test]
fn test_cyclic_dependency() {
let tasks = vec![
create_task("A", 1, 3, vec!["B"]),
create_task("B", 2, 4, vec!["A"]),
];
let schedule = Schedule::new(tasks, 2, 0.8);
assert!(schedule.is_err());
assert!(schedule
.unwrap_err()
.starts_with("Cyclic dependency detected involving task"));
}
#[test]
fn test_complex_valid_schedule() {
let tasks = vec![
create_task("A", 1, 3, vec![]),
create_task("B", 2, 4, vec!["A"]),
create_task("C", 3, 5, vec!["A"]),
create_task("D", 2, 6, vec!["B", "C"]),
create_task("E", 1, 2, vec!["A"]),
create_task("F", 2, 4, vec!["D", "E"]),
];
let schedule = Schedule::new(tasks, 3, 0.9);
assert!(schedule.is_ok());
}
#[test]
fn test_complex_cyclic_dependency() {
let tasks = vec![
create_task("A", 1, 3, vec!["F"]),
create_task("B", 2, 4, vec!["A"]),
create_task("C", 3, 5, vec!["B"]),
create_task("D", 2, 6, vec!["C"]),
create_task("E", 1, 2, vec!["D"]),
create_task("F", 2, 4, vec!["E"]),
];
let schedule = Schedule::new(tasks, 3, 0.9);
assert!(
schedule.is_err(),
"Expected cyclic dependency error, but got Ok"
);
if let Err(err) = schedule {
assert!(
err.starts_with("Cyclic dependency detected"),
"Unexpected error message: {}",
err
);
}
}
#[test]
fn test_self_dependency() {
let tasks = vec![create_task("A", 1, 3, vec!["A"])];
let schedule = Schedule::new(tasks, 1, 0.8);
assert!(
schedule.is_err(),
"Expected self-dependency error, but got Ok"
);
if let Err(err) = schedule {
assert!(
err.starts_with("Cyclic dependency detected"),
"Unexpected error message: {}",
err
);
}
}
}
+227
View File
@@ -0,0 +1,227 @@
use crate::{schedule::Schedule, task::Task};
use serde::{Deserialize, Serialize};
use std::{fs::File, io::Read, path::Path, time::Duration};
#[derive(Debug, Deserialize, Serialize)]
struct ScheduleInput {
num_workers: usize,
estimate_confidence: f64,
tasks: Vec<TaskInput>,
}
#[derive(Debug, Deserialize, Serialize)]
struct TaskInput {
id: String,
min_time: f64,
max_time: f64,
dependencies: Vec<String>,
}
enum FileFormat {
Yaml,
Json,
}
impl Schedule {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
let mut file = File::open(&path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let format = Self::detect_format(&path)?;
let input: ScheduleInput = match format {
FileFormat::Yaml => serde_yaml::from_str(&contents)?,
FileFormat::Json => serde_json::from_str(&contents)?,
};
let tasks = input
.tasks
.into_iter()
.map(|t| Task {
id: t.id,
min_time: Duration::from_secs_f64(t.min_time * 24.0 * 60.0 * 60.0),
max_time: Duration::from_secs_f64(t.max_time * 24.0 * 60.0 * 60.0),
dependencies: t.dependencies,
})
.collect();
let schedule = Schedule::new(tasks, input.num_workers, input.estimate_confidence)?;
schedule.validate()?;
Ok(schedule)
}
fn detect_format<P: AsRef<Path>>(path: P) -> Result<FileFormat, Box<dyn std::error::Error>> {
let extension = path
.as_ref()
.extension()
.and_then(std::ffi::OsStr::to_str)
.ok_or("File has no extension")?
.to_lowercase();
match extension.as_str() {
"yaml" | "yml" => Ok(FileFormat::Yaml),
"json" => Ok(FileFormat::Json),
_ => Err("Unsupported file format. Use .yaml, .yml, or .json".into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_temp_file(content: &str, extension: &str) -> (NamedTempFile, std::path::PathBuf) {
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "{}", content).unwrap();
let path = temp_file.path().to_owned();
let new_path = path.with_extension(extension);
std::fs::rename(&path, &new_path).unwrap();
(temp_file, new_path)
}
#[test]
fn test_load_from_yaml() {
let yaml_content = r#"
num_workers: 3
estimate_confidence: 0.9
tasks:
- id: A
min_time: 1
max_time: 3
dependencies: []
- id: B
min_time: 2
max_time: 4
dependencies: [A]
"#;
let (_temp_file, path) = create_temp_file(yaml_content, "yaml");
let schedule = Schedule::from_file(path).unwrap();
assert_eq!(schedule.tasks.len(), 2);
assert_eq!(schedule.num_workers, 3);
assert_eq!(schedule.estimate_confidence, 0.9);
}
#[test]
fn test_load_from_json() {
let json_content = r#"
{
"num_workers": 3,
"estimate_confidence": 0.9,
"tasks": [
{
"id": "A",
"min_time": 1,
"max_time": 3,
"dependencies": []
},
{
"id": "B",
"min_time": 2,
"max_time": 4,
"dependencies": ["A"]
}
]
}
"#;
let (_temp_file, path) = create_temp_file(json_content, "json");
let schedule = Schedule::from_file(path).unwrap();
assert_eq!(schedule.tasks.len(), 2);
assert_eq!(schedule.num_workers, 3);
assert_eq!(schedule.estimate_confidence, 0.9);
}
#[test]
fn test_unsupported_format() {
let content = "irrelevant content";
let (_temp_file, path) = create_temp_file(content, "txt");
let result = Schedule::from_file(path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported file format"));
}
#[test]
fn test_invalid_schedule() {
let yaml_content = r#"
num_workers: 3
estimate_confidence: 0.9
tasks:
- id: A
min_time: 3
max_time: 1 # Invalid: min_time > max_time
dependencies: []
"#;
let (_temp_file, path) = create_temp_file(yaml_content, "yaml");
let result = Schedule::from_file(path);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Minimum duration greater than maximum for task A"));
}
#[test]
fn test_load_with_floating_point_times_yaml() {
let yaml_content = r#"
num_workers: 3
estimate_confidence: 0.9
tasks:
- id: A
min_time: 1.5 # 1.5 days
max_time: 3.5 # 3.5 days
dependencies: []
"#;
let (_temp_file, path) = create_temp_file(yaml_content, "yaml");
let schedule = Schedule::from_file(path).unwrap();
// Verify that there is one task and it has the correct times
assert_eq!(schedule.tasks.len(), 1);
assert_eq!(
schedule.tasks[0].min_time.as_secs_f64(),
1.5 * 24.0 * 60.0 * 60.0
);
assert_eq!(
schedule.tasks[0].max_time.as_secs_f64(),
3.5 * 24.0 * 60.0 * 60.0
);
}
#[test]
fn test_load_with_floating_point_times_json() {
let json_content = r#"
{
"num_workers": 3,
"estimate_confidence": 0.9,
"tasks": [
{
"id": "A",
"min_time": 1.5,
"max_time": 3.5,
"dependencies": []
}
]
}
"#;
let (_temp_file, path) = create_temp_file(json_content, "json");
let schedule = Schedule::from_file(path).unwrap();
// Verify that there is one task and it has the correct times
assert_eq!(schedule.tasks.len(), 1);
assert_eq!(
schedule.tasks[0].min_time.as_secs_f64(),
1.5 * 24.0 * 60.0 * 60.0
);
assert_eq!(
schedule.tasks[0].max_time.as_secs_f64(),
3.5 * 24.0 * 60.0 * 60.0
);
}
}
+262
View File
@@ -0,0 +1,262 @@
use crate::schedule::Schedule;
use rand::prelude::*;
use rand_distr::{Distribution, Normal};
use rayon::prelude::*;
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
pub struct SimulationResult {
pub total_project_duration: Duration,
pub total_effort_time: Duration,
}
pub fn run_multiple_simulations(
schedule: &Schedule,
num_simulations: usize,
) -> (Vec<Duration>, Vec<Duration>) {
(0..num_simulations)
.into_par_iter()
.map(|_| {
let result = run_simulation(schedule);
(result.total_project_duration, result.total_effort_time)
})
.unzip()
}
fn run_simulation(schedule: &Schedule) -> SimulationResult {
let mut rng = thread_rng();
// Simulate task times
let task_effort_times: HashMap<_, _> = schedule
.tasks
.iter()
.map(|task| {
let task_time = simulate_task_time(
&mut rng,
task.min_time,
task.max_time,
schedule.estimate_confidence,
);
(&task.id, task_time)
})
.collect();
let total_effort_time: Duration = task_effort_times.values().sum();
// Pre-compute task dependencies and reverse dependencies
let mut task_dependencies: HashMap<_, HashSet<_>> = HashMap::new();
let mut reverse_dependencies: HashMap<_, Vec<_>> = HashMap::new();
for task in &schedule.tasks {
task_dependencies.insert(&task.id, task.dependencies.iter().collect());
for dep in &task.dependencies {
reverse_dependencies.entry(dep).or_default().push(&task.id);
}
}
// Initialize task queue with tasks that have no dependencies
let mut task_queue: Vec<_> = schedule
.tasks
.iter()
.filter(|t| t.dependencies.is_empty())
.map(|t| &t.id)
.collect();
let mut current_time = Duration::default();
let mut completed_tasks = HashSet::new();
let mut worker_finish_times = vec![Duration::default(); schedule.num_workers];
while !task_queue.is_empty() || completed_tasks.len() < schedule.tasks.len() {
// Find all available workers
let available_workers: Vec<_> = worker_finish_times
.iter()
.enumerate()
.filter(|&(_, &time)| time <= current_time)
.map(|(i, _)| i)
.collect();
if !task_queue.is_empty() && !available_workers.is_empty() {
for &worker in &available_workers {
if task_queue.is_empty() {
break;
}
// Randomly choose the next task to assign
let task_index = rng.gen_range(0..task_queue.len());
let task_id = task_queue.swap_remove(task_index);
let task_duration = task_effort_times[task_id];
worker_finish_times[worker] = current_time + task_duration;
completed_tasks.insert(task_id);
// Add newly available tasks to queue
if let Some(dependent_tasks) = reverse_dependencies.get(task_id) {
for &dep_task in dependent_tasks {
if !completed_tasks.contains(dep_task)
&& !task_queue.contains(&dep_task)
&& task_dependencies[dep_task]
.iter()
.all(|dep| completed_tasks.contains(dep))
{
task_queue.push(dep_task);
}
}
}
}
}
// Move time forward to the next event
current_time = *worker_finish_times.iter().min().unwrap_or(&current_time);
}
SimulationResult {
total_project_duration: *worker_finish_times.iter().max().unwrap_or(&current_time),
total_effort_time,
}
}
fn simulate_task_time(
rng: &mut impl Rng,
min_time: Duration,
max_time: Duration,
estimate_confidence: f64,
) -> Duration {
let min_secs = min_time.as_secs_f64();
let max_secs = max_time.as_secs_f64();
let mean = (min_secs + max_secs) / 2.0;
let z_score = (1.0 - (1.0 - estimate_confidence) / 2.0).sqrt() * 2.0;
let std_dev = (max_secs - min_secs) / (2.0 * z_score);
let normal = Normal::new(mean, std_dev).unwrap();
let sampled_secs = normal.sample(rng).clamp(min_secs, max_secs);
Duration::from_secs_f64(sampled_secs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task::Task;
#[test]
fn test_simulate_task_time() {
let mut rng = thread_rng();
let min_time = Duration::from_secs(5);
let max_time = Duration::from_secs(15);
let estimate_confidence = 0.8;
for _ in 0..1000 {
let time = simulate_task_time(&mut rng, min_time, max_time, estimate_confidence);
assert!(
time >= min_time && time <= max_time,
"Simulated time should be within range"
);
}
}
#[test]
fn test_single_task_simulation() {
let task = Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(15));
let schedule = Schedule::new(vec![task], 1, 0.8).unwrap();
let result = run_simulation(&schedule);
assert_eq!(
result.total_project_duration, result.total_effort_time,
"For a single task, project duration should equal effort time"
);
assert!(
result.total_project_duration >= Duration::from_secs(5)
&& result.total_project_duration <= Duration::from_secs(15),
"Project duration should be within the task's time range"
);
}
#[test]
fn test_multiple_independent_tasks() {
let tasks = vec![
Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)),
Task::new("B", vec![], Duration::from_secs(7), Duration::from_secs(12)),
Task::new("C", vec![], Duration::from_secs(3), Duration::from_secs(8)),
];
let schedule = Schedule::new(tasks, 2, 0.8).unwrap();
let result = run_simulation(&schedule);
assert!(
result.total_project_duration <= result.total_effort_time,
"Project duration should not exceed total effort time"
);
assert!(
result.total_project_duration >= Duration::from_secs(7),
"Project duration should be at least the longest minimum task duration"
);
}
#[test]
fn test_tasks_with_dependencies() {
let tasks = vec![
Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)),
Task::new(
"B",
vec!["A".to_string()],
Duration::from_secs(7),
Duration::from_secs(12),
),
Task::new(
"C",
vec!["A".to_string()],
Duration::from_secs(3),
Duration::from_secs(8),
),
Task::new(
"D",
vec!["B".to_string(), "C".to_string()],
Duration::from_secs(4),
Duration::from_secs(9),
),
];
let schedule = Schedule::new(tasks, 2, 0.8).unwrap();
let result = run_simulation(&schedule);
assert!(
result.total_project_duration >= Duration::from_secs(14),
"Project duration should be at least the minimum critical path duration"
);
}
#[test]
fn test_multiple_simulations_consistency() {
let tasks = vec![
Task::new("A", vec![], Duration::from_secs(5), Duration::from_secs(10)),
Task::new(
"B",
vec!["A".to_string()],
Duration::from_secs(7),
Duration::from_secs(12),
),
];
let schedule = Schedule::new(tasks, 1, 0.8).unwrap();
let (durations, efforts) = run_multiple_simulations(&schedule, 1000);
assert_eq!(durations.len(), 1000, "Should run 1000 simulations");
assert_eq!(efforts.len(), 1000, "Should run 1000 simulations");
let avg_duration: Duration = durations.iter().sum::<Duration>() / 1000;
let avg_effort: Duration = efforts.iter().sum::<Duration>() / 1000;
assert!(
avg_duration >= Duration::from_secs(12) && avg_duration <= Duration::from_secs(22),
"Average duration should be within expected range"
);
assert!(
avg_effort >= Duration::from_secs(12) && avg_effort <= Duration::from_secs(22),
"Average effort should be within expected range"
);
}
}
+149
View File
@@ -0,0 +1,149 @@
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct Task {
pub id: String,
pub dependencies: Vec<String>,
pub min_time: Duration,
pub max_time: Duration,
}
impl Task {
pub fn new(
id: &str,
dependencies: Vec<String>,
min_time: Duration,
max_time: Duration,
) -> Self {
Task {
id: id.to_string(),
dependencies,
min_time,
max_time,
}
}
}
pub fn days_to_duration(days: f64) -> Duration {
Duration::from_secs_f64(days * 24.0 * 60.0 * 60.0)
}
#[cfg(test)]
mod tests {
use super::*;
const EPSILON: f64 = 1e-6;
fn assert_duration_eq(actual: Duration, expected: Duration) {
let actual_secs = actual.as_secs_f64();
let expected_secs = expected.as_secs_f64();
assert!(
(actual_secs - expected_secs).abs() < EPSILON,
"Durations are not equal: actual = {:?}, expected = {:?}",
actual,
expected
);
}
#[test]
fn test_task_creation() {
let task = Task::new(
"Task1",
vec!["Dep1".to_string(), "Dep2".to_string()],
Duration::from_secs(1),
Duration::from_secs(5),
);
assert_eq!(task.id, "Task1");
assert_eq!(task.dependencies, vec!["Dep1", "Dep2"]);
assert_eq!(task.min_time, Duration::from_secs(1));
assert_eq!(task.max_time, Duration::from_secs(5));
}
#[test]
fn test_task_creation_empty_dependencies() {
let task = Task::new(
"Task2",
vec![],
Duration::from_secs(2),
Duration::from_secs(10),
);
assert_eq!(task.id, "Task2");
assert!(task.dependencies.is_empty());
assert_eq!(task.min_time, Duration::from_secs(2));
assert_eq!(task.max_time, Duration::from_secs(10));
}
#[test]
fn test_task_creation_same_min_max_time() {
let task = Task::new(
"Task3",
vec!["Dep3".to_string()],
Duration::from_secs(5),
Duration::from_secs(5),
);
assert_eq!(task.id, "Task3");
assert_eq!(task.dependencies, vec!["Dep3"]);
assert_eq!(task.min_time, task.max_time);
}
#[test]
fn test_task_clone() {
let task1 = Task::new(
"Task4",
vec!["Dep4".to_string()],
Duration::from_secs(3),
Duration::from_secs(7),
);
let task2 = task1.clone();
assert_eq!(task1.id, task2.id);
assert_eq!(task1.dependencies, task2.dependencies);
assert_eq!(task1.min_time, task2.min_time);
assert_eq!(task1.max_time, task2.max_time);
}
#[test]
fn test_days_to_duration() {
assert_duration_eq(days_to_duration(1.0), Duration::from_secs(24 * 60 * 60));
assert_duration_eq(days_to_duration(7.0), Duration::from_secs(7 * 24 * 60 * 60));
assert_duration_eq(days_to_duration(0.0), Duration::from_secs(0));
assert_duration_eq(
days_to_duration(365.0),
Duration::from_secs(365 * 24 * 60 * 60),
);
}
#[test]
fn test_days_to_duration_fractional() {
assert_duration_eq(days_to_duration(0.5), Duration::from_secs(12 * 60 * 60));
assert_duration_eq(days_to_duration(1.5), Duration::from_secs(36 * 60 * 60));
assert_duration_eq(days_to_duration(0.25), Duration::from_secs(6 * 60 * 60));
assert_duration_eq(
days_to_duration(0.1),
Duration::from_secs_f64(0.1 * 24.0 * 60.0 * 60.0),
);
}
#[test]
fn test_days_to_duration_large_number() {
let large_days = 1000.0;
let expected_seconds = large_days * 24.0 * 60.0 * 60.0;
assert_duration_eq(
days_to_duration(large_days),
Duration::from_secs_f64(expected_seconds),
);
}
#[test]
fn test_days_to_duration_very_small() {
let small_days = 1e-6;
let expected_seconds = small_days * 24.0 * 60.0 * 60.0;
assert_duration_eq(
days_to_duration(small_days),
Duration::from_secs_f64(expected_seconds),
);
}
}