use crate::DisplayMode;
use std::collections::HashMap;
use utf8proj_core::{Project, RenderError, Renderer, Schedule, ScheduledTask};
#[derive(Clone, Debug)]
pub struct PlantUmlRenderer {
pub show_critical: bool,
pub critical_color: String,
pub normal_color: String,
pub show_completion: bool,
pub use_dependencies: bool,
pub close_weekends: bool,
pub show_aliases: bool,
pub scale: Option<String>,
pub show_today: bool,
pub display_mode: DisplayMode,
pub label_width: usize,
}
impl Default for PlantUmlRenderer {
fn default() -> Self {
Self {
show_critical: true,
critical_color: "OrangeRed".into(),
normal_color: "SteelBlue".into(),
show_completion: true,
use_dependencies: true,
close_weekends: true,
show_aliases: true,
scale: None,
show_today: false,
display_mode: DisplayMode::Name,
label_width: 40,
}
}
}
impl PlantUmlRenderer {
pub fn new() -> Self {
Self::default()
}
pub fn no_critical(mut self) -> Self {
self.show_critical = false;
self
}
pub fn critical_color(mut self, color: impl Into<String>) -> Self {
self.critical_color = color.into();
self
}
pub fn normal_color(mut self, color: impl Into<String>) -> Self {
self.normal_color = color.into();
self
}
pub fn no_completion(mut self) -> Self {
self.show_completion = false;
self
}
pub fn absolute_dates(mut self) -> Self {
self.use_dependencies = false;
self
}
pub fn include_weekends(mut self) -> Self {
self.close_weekends = false;
self
}
pub fn no_aliases(mut self) -> Self {
self.show_aliases = false;
self
}
pub fn scale(mut self, scale: impl Into<String>) -> Self {
self.scale = Some(scale.into());
self
}
pub fn show_today(mut self) -> Self {
self.show_today = true;
self
}
pub fn display_mode(mut self, mode: DisplayMode) -> Self {
self.display_mode = mode;
self
}
pub fn label_width(mut self, width: usize) -> Self {
self.label_width = width;
self
}
fn sanitize_name(name: &str) -> String {
name.replace('[', "(")
.replace(']', ")")
.replace('\n', " ")
.replace('\r', "")
}
fn make_alias(task_id: &str) -> String {
task_id
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn format_duration(task: &ScheduledTask) -> String {
let days = task.duration.as_days().ceil() as i64;
if days == 0 {
"0 days".into()
} else if days == 1 {
"1 day".into()
} else {
format!("{} days", days)
}
}
}
impl Renderer for PlantUmlRenderer {
type Output = String;
fn render(&self, project: &Project, schedule: &Schedule) -> Result<String, RenderError> {
if schedule.tasks.is_empty() {
return Err(RenderError::InvalidData("No tasks to render".into()));
}
let mut output = String::new();
output.push_str("@startgantt\n");
output.push_str(&format!(
"Project starts {}\n",
project.start.format("%Y-%m-%d")
));
if let Some(ref scale) = self.scale {
output.push_str(&format!("printscale {}\n", scale));
}
if self.close_weekends {
output.push_str("saturday are closed\n");
output.push_str("sunday are closed\n");
}
if self.show_today {
output.push_str("today is colored in LightBlue\n");
}
output.push('\n');
let mut tasks: Vec<(&String, &ScheduledTask)> = schedule.tasks.iter().collect();
tasks.sort_by_key(|(_, t)| t.start);
let mut first_predecessor: HashMap<String, String> = HashMap::new();
for task in &project.tasks {
self.collect_predecessors(task, &mut first_predecessor);
}
let mut rendered_tasks: std::collections::HashSet<String> =
std::collections::HashSet::new();
for (task_id, scheduled) in &tasks {
let task = project.get_task(task_id);
let name = task
.map(|t| t.name.clone())
.unwrap_or_else(|| (*task_id).clone());
let complete = task.and_then(|t| t.complete);
let label = self
.display_mode
.format_label(task_id, &name, self.label_width);
let sanitized_name = Self::sanitize_name(&label);
let alias = Self::make_alias(task_id);
let is_milestone = scheduled.duration.minutes == 0;
if is_milestone {
if self.use_dependencies {
if let Some(pred) = first_predecessor.get(*task_id) {
let pred_alias = Self::make_alias(pred);
if rendered_tasks.contains(pred) {
output.push_str(&format!(
"[{}] happens at [{}]'s end\n",
sanitized_name, pred_alias
));
} else {
output.push_str(&format!(
"[{}] happens {}\n",
sanitized_name,
scheduled.start.format("%Y-%m-%d")
));
}
} else {
output.push_str(&format!(
"[{}] happens {}\n",
sanitized_name,
scheduled.start.format("%Y-%m-%d")
));
}
} else {
output.push_str(&format!(
"[{}] happens {}\n",
sanitized_name,
scheduled.start.format("%Y-%m-%d")
));
}
} else {
let duration = Self::format_duration(scheduled);
let task_def = if self.show_aliases {
format!("[{}] as [{}]", sanitized_name, alias)
} else {
format!("[{}]", sanitized_name)
};
if self.use_dependencies {
if let Some(pred) = first_predecessor.get(*task_id) {
let pred_alias = Self::make_alias(pred);
if rendered_tasks.contains(pred) {
output.push_str(&format!(
"{} starts at [{}]'s end and lasts {}\n",
task_def, pred_alias, duration
));
} else {
output.push_str(&format!(
"{} starts {} and lasts {}\n",
task_def,
scheduled.start.format("%Y-%m-%d"),
duration
));
}
} else {
output.push_str(&format!(
"{} starts {} and lasts {}\n",
task_def,
scheduled.start.format("%Y-%m-%d"),
duration
));
}
} else {
output.push_str(&format!(
"{} starts {} and lasts {}\n",
task_def,
scheduled.start.format("%Y-%m-%d"),
duration
));
}
}
if self.show_critical && scheduled.is_critical && !is_milestone {
let ref_name = if self.show_aliases {
alias.clone()
} else {
sanitized_name.clone()
};
output.push_str(&format!(
"[{}] is colored in {}\n",
ref_name, self.critical_color
));
}
if self.show_completion && !is_milestone {
if let Some(pct) = complete {
if pct > 0.0 {
let ref_name = if self.show_aliases {
alias.clone()
} else {
sanitized_name.clone()
};
output.push_str(&format!(
"[{}] is {}% complete\n",
ref_name,
pct.round() as i32
));
}
}
}
rendered_tasks.insert((*task_id).clone());
}
output.push_str("@endgantt\n");
Ok(output)
}
}
impl PlantUmlRenderer {
fn collect_predecessors(&self, task: &utf8proj_core::Task, map: &mut HashMap<String, String>) {
if let Some(first_dep) = task.depends.first() {
map.insert(task.id.clone(), first_dep.predecessor.clone());
}
for child in &task.children {
self.collect_predecessors(child, map);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use utf8proj_core::{Duration, Schedule, ScheduledTask, Task, TaskStatus};
fn create_test_project() -> Project {
let mut project = Project::new("Test Project");
project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
project.tasks.push(
Task::new("design")
.name("Design Phase")
.effort(Duration::days(5)),
);
project.tasks.push(
Task::new("implement")
.name("Implementation")
.effort(Duration::days(10))
.depends_on("design"),
);
project.tasks.push(
Task::new("test")
.name("Testing")
.effort(Duration::days(3))
.depends_on("implement"),
);
project
}
fn create_test_schedule() -> Schedule {
let mut tasks = HashMap::new();
let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
tasks.insert(
"design".to_string(),
ScheduledTask {
task_id: "design".to_string(),
start: start1,
finish: finish1,
duration: Duration::days(5),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: start1,
early_finish: finish1,
late_start: start1,
late_finish: finish1,
forecast_start: start1,
forecast_finish: finish1,
remaining_duration: Duration::days(5),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start1,
baseline_finish: finish1,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let start2 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
let finish2 = NaiveDate::from_ymd_opt(2025, 1, 24).unwrap();
tasks.insert(
"implement".to_string(),
ScheduledTask {
task_id: "implement".to_string(),
start: start2,
finish: finish2,
duration: Duration::days(10),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: start2,
early_finish: finish2,
late_start: start2,
late_finish: finish2,
forecast_start: start2,
forecast_finish: finish2,
remaining_duration: Duration::days(10),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start2,
baseline_finish: finish2,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let start3 = NaiveDate::from_ymd_opt(2025, 1, 27).unwrap();
let finish3 = NaiveDate::from_ymd_opt(2025, 1, 29).unwrap();
tasks.insert(
"test".to_string(),
ScheduledTask {
task_id: "test".to_string(),
start: start3,
finish: finish3,
duration: Duration::days(3),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: start3,
early_finish: finish3,
late_start: start3,
late_finish: finish3,
forecast_start: start3,
forecast_finish: finish3,
remaining_duration: Duration::days(3),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start3,
baseline_finish: finish3,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let project_end = NaiveDate::from_ymd_opt(2025, 1, 29).unwrap();
Schedule {
tasks,
critical_path: vec![
"design".to_string(),
"implement".to_string(),
"test".to_string(),
],
project_duration: Duration::days(18),
project_end,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: project_end,
project_forecast_finish: project_end,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
}
}
#[test]
fn plantuml_renderer_creation() {
let renderer = PlantUmlRenderer::new();
assert!(renderer.show_critical);
assert!(renderer.close_weekends);
assert_eq!(renderer.critical_color, "OrangeRed");
}
#[test]
fn plantuml_renderer_with_options() {
let renderer = PlantUmlRenderer::new()
.no_critical()
.include_weekends()
.absolute_dates()
.scale("week");
assert!(!renderer.show_critical);
assert!(!renderer.close_weekends);
assert!(!renderer.use_dependencies);
assert_eq!(renderer.scale, Some("week".to_string()));
}
#[test]
fn plantuml_produces_valid_output() {
let renderer = PlantUmlRenderer::new();
let project = create_test_project();
let schedule = create_test_schedule();
let result = renderer.render(&project, &schedule);
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.starts_with("@startgantt\n"));
assert!(output.ends_with("@endgantt\n"));
assert!(output.contains("Project starts 2025-01-06"));
}
#[test]
fn plantuml_includes_weekend_closure() {
let renderer = PlantUmlRenderer::new();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("saturday are closed"));
assert!(output.contains("sunday are closed"));
}
#[test]
fn plantuml_no_weekend_closure() {
let renderer = PlantUmlRenderer::new().include_weekends();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(!output.contains("saturday are closed"));
assert!(!output.contains("sunday are closed"));
}
#[test]
fn plantuml_includes_critical_coloring() {
let renderer = PlantUmlRenderer::new();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("is colored in OrangeRed"));
}
#[test]
fn plantuml_custom_colors() {
let renderer = PlantUmlRenderer::new().critical_color("Red");
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("is colored in Red"));
}
#[test]
fn plantuml_uses_dependency_syntax() {
let renderer = PlantUmlRenderer::new();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("starts at [design]'s end"));
assert!(output.contains("starts at [implement]'s end"));
}
#[test]
fn plantuml_absolute_dates_mode() {
let renderer = PlantUmlRenderer::new().absolute_dates();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(!output.contains("starts at ["));
assert!(output.contains("starts 2025-01-06"));
assert!(output.contains("starts 2025-01-13"));
}
#[test]
fn plantuml_empty_schedule_fails() {
let renderer = PlantUmlRenderer::new();
let project = Project::new("Empty");
let project_end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let schedule = Schedule {
tasks: HashMap::new(),
critical_path: vec![],
project_duration: Duration::zero(),
project_end,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: project_end,
project_forecast_finish: project_end,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
};
let result = renderer.render(&project, &schedule);
assert!(result.is_err());
}
#[test]
fn plantuml_sanitizes_special_chars() {
assert_eq!(PlantUmlRenderer::sanitize_name("Task [1]"), "Task (1)");
assert_eq!(PlantUmlRenderer::sanitize_name("Test\nTask"), "Test Task");
}
#[test]
fn plantuml_makes_valid_aliases() {
assert_eq!(PlantUmlRenderer::make_alias("task1"), "task1");
assert_eq!(
PlantUmlRenderer::make_alias("phase1.design"),
"phase1_design"
);
assert_eq!(
PlantUmlRenderer::make_alias("task-with-dashes"),
"task_with_dashes"
);
}
#[test]
fn plantuml_milestone_detection() {
let mut project = Project::new("Milestone Test");
project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
project
.tasks
.push(Task::new("work").name("Work").effort(Duration::days(5)));
project.tasks.push(
Task::new("done")
.name("Project Complete")
.milestone()
.depends_on("work"),
);
let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
let ms_date = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
let mut tasks = HashMap::new();
tasks.insert(
"work".to_string(),
ScheduledTask {
task_id: "work".to_string(),
start: start1,
finish: finish1,
duration: Duration::days(5),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: start1,
early_finish: finish1,
late_start: start1,
late_finish: finish1,
forecast_start: start1,
forecast_finish: finish1,
remaining_duration: Duration::days(5),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start1,
baseline_finish: finish1,
start_variance_days: 0,
finish_variance_days: 0,
},
);
tasks.insert(
"done".to_string(),
ScheduledTask {
task_id: "done".to_string(),
start: ms_date,
finish: ms_date,
duration: Duration::zero(),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: ms_date,
early_finish: ms_date,
late_start: ms_date,
late_finish: ms_date,
forecast_start: ms_date,
forecast_finish: ms_date,
remaining_duration: Duration::zero(),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: ms_date,
baseline_finish: ms_date,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let project_end = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
let schedule = Schedule {
tasks,
critical_path: vec!["work".to_string(), "done".to_string()],
project_duration: Duration::days(5),
project_end,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: project_end,
project_forecast_finish: project_end,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
};
let renderer = PlantUmlRenderer::new();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("happens at"));
}
#[test]
fn plantuml_with_scale() {
let renderer = PlantUmlRenderer::new().scale("week");
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("printscale week"));
}
#[test]
fn plantuml_with_today_marker() {
let renderer = PlantUmlRenderer::new().show_today();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("today is colored in LightBlue"));
}
#[test]
fn plantuml_task_aliases() {
let renderer = PlantUmlRenderer::new();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("as [design]"));
assert!(output.contains("as [implement]"));
}
#[test]
fn plantuml_no_aliases() {
let renderer = PlantUmlRenderer::new().no_aliases();
let project = create_test_project();
let schedule = create_test_schedule();
let output = renderer.render(&project, &schedule).unwrap();
assert!(!output.contains(" as ["));
}
#[test]
fn plantuml_normal_color_option() {
let renderer = PlantUmlRenderer::new().normal_color("GreenYellow");
assert_eq!(renderer.normal_color, "GreenYellow");
}
#[test]
fn plantuml_no_completion_option() {
let renderer = PlantUmlRenderer::new().no_completion();
assert!(!renderer.show_completion);
}
#[test]
fn plantuml_format_duration_zero() {
let date = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let task = ScheduledTask {
task_id: "ms".to_string(),
start: date,
finish: date,
duration: Duration::zero(),
assignments: vec![],
slack: Duration::zero(),
is_critical: false,
early_start: date,
early_finish: date,
late_start: date,
late_finish: date,
forecast_start: date,
forecast_finish: date,
remaining_duration: Duration::zero(),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: date,
baseline_finish: date,
start_variance_days: 0,
finish_variance_days: 0,
};
assert_eq!(PlantUmlRenderer::format_duration(&task), "0 days");
}
#[test]
fn plantuml_format_duration_one_day() {
let date = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let task = ScheduledTask {
task_id: "quick".to_string(),
start: date,
finish: date,
duration: Duration::days(1),
assignments: vec![],
slack: Duration::zero(),
is_critical: false,
early_start: date,
early_finish: date,
late_start: date,
late_finish: date,
forecast_start: date,
forecast_finish: date,
remaining_duration: Duration::days(1),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: date,
baseline_finish: date,
start_variance_days: 0,
finish_variance_days: 0,
};
assert_eq!(PlantUmlRenderer::format_duration(&task), "1 day");
}
#[test]
fn plantuml_with_completion() {
let mut project = Project::new("Completion Test");
project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let mut task = Task::new("work").name("Work").effort(Duration::days(5));
task.complete = Some(50.0);
project.tasks.push(task);
let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
let mut tasks = HashMap::new();
tasks.insert(
"work".to_string(),
ScheduledTask {
task_id: "work".to_string(),
start: start1,
finish: finish1,
duration: Duration::days(5),
assignments: vec![],
slack: Duration::zero(),
is_critical: false,
early_start: start1,
early_finish: finish1,
late_start: start1,
late_finish: finish1,
forecast_start: start1,
forecast_finish: finish1,
remaining_duration: Duration::days(5),
percent_complete: 50,
status: TaskStatus::InProgress,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start1,
baseline_finish: finish1,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let project_end = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
let schedule = Schedule {
tasks,
critical_path: vec![],
project_duration: Duration::days(5),
project_end,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: project_end,
project_forecast_finish: project_end,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
};
let renderer = PlantUmlRenderer::new();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("is 50% complete"));
}
#[test]
fn plantuml_no_completion_hides_percentage() {
let mut project = Project::new("Completion Test");
project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let mut task = Task::new("work").name("Work").effort(Duration::days(5));
task.complete = Some(75.0);
project.tasks.push(task);
let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
let mut tasks = HashMap::new();
tasks.insert(
"work".to_string(),
ScheduledTask {
task_id: "work".to_string(),
start: start1,
finish: finish1,
duration: Duration::days(5),
assignments: vec![],
slack: Duration::zero(),
is_critical: false,
early_start: start1,
early_finish: finish1,
late_start: start1,
late_finish: finish1,
forecast_start: start1,
forecast_finish: finish1,
remaining_duration: Duration::days(5),
percent_complete: 75,
status: TaskStatus::InProgress,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start1,
baseline_finish: finish1,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let project_end = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
let schedule = Schedule {
tasks,
critical_path: vec![],
project_duration: Duration::days(5),
project_end,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: project_end,
project_forecast_finish: project_end,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
};
let renderer = PlantUmlRenderer::new().no_completion();
let output = renderer.render(&project, &schedule).unwrap();
assert!(!output.contains("% complete"));
}
#[test]
fn plantuml_milestone_without_predecessor() {
let mut project = Project::new("Milestone No Dep");
project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
project
.tasks
.push(Task::new("kickoff").name("Project Kickoff").milestone());
let ms_date = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let mut tasks = HashMap::new();
tasks.insert(
"kickoff".to_string(),
ScheduledTask {
task_id: "kickoff".to_string(),
start: ms_date,
finish: ms_date,
duration: Duration::zero(),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: ms_date,
early_finish: ms_date,
late_start: ms_date,
late_finish: ms_date,
forecast_start: ms_date,
forecast_finish: ms_date,
remaining_duration: Duration::zero(),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: ms_date,
baseline_finish: ms_date,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let schedule = Schedule {
tasks,
critical_path: vec!["kickoff".to_string()],
project_duration: Duration::zero(),
project_end: ms_date,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: ms_date,
project_forecast_finish: ms_date,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
};
let renderer = PlantUmlRenderer::new();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("happens 2025-01-06"));
}
#[test]
fn plantuml_milestone_absolute_dates_mode() {
let mut project = Project::new("Milestone Absolute");
project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
project
.tasks
.push(Task::new("work").name("Work").effort(Duration::days(5)));
project.tasks.push(
Task::new("done")
.name("Complete")
.milestone()
.depends_on("work"),
);
let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
let ms_date = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
let mut tasks = HashMap::new();
tasks.insert(
"work".to_string(),
ScheduledTask {
task_id: "work".to_string(),
start: start1,
finish: finish1,
duration: Duration::days(5),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: start1,
early_finish: finish1,
late_start: start1,
late_finish: finish1,
forecast_start: start1,
forecast_finish: finish1,
remaining_duration: Duration::days(5),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start1,
baseline_finish: finish1,
start_variance_days: 0,
finish_variance_days: 0,
},
);
tasks.insert(
"done".to_string(),
ScheduledTask {
task_id: "done".to_string(),
start: ms_date,
finish: ms_date,
duration: Duration::zero(),
assignments: vec![],
slack: Duration::zero(),
is_critical: true,
early_start: ms_date,
early_finish: ms_date,
late_start: ms_date,
late_finish: ms_date,
forecast_start: ms_date,
forecast_finish: ms_date,
remaining_duration: Duration::zero(),
percent_complete: 0,
status: TaskStatus::NotStarted,
cost_range: None,
has_abstract_assignments: false,
baseline_start: ms_date,
baseline_finish: ms_date,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let schedule = Schedule {
tasks,
critical_path: vec!["work".to_string(), "done".to_string()],
project_duration: Duration::days(5),
project_end: ms_date,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: ms_date,
project_forecast_finish: ms_date,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
};
let renderer = PlantUmlRenderer::new().absolute_dates();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("happens 2025-01-13"));
assert!(!output.contains("happens at [")); }
#[test]
fn plantuml_completion_without_aliases() {
let mut project = Project::new("No Alias Completion");
project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let mut task = Task::new("work")
.name("Work Task")
.effort(Duration::days(5));
task.complete = Some(60.0);
project.tasks.push(task);
let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
let mut tasks = HashMap::new();
tasks.insert(
"work".to_string(),
ScheduledTask {
task_id: "work".to_string(),
start: start1,
finish: finish1,
duration: Duration::days(5),
assignments: vec![],
slack: Duration::zero(),
is_critical: false,
early_start: start1,
early_finish: finish1,
late_start: start1,
late_finish: finish1,
forecast_start: start1,
forecast_finish: finish1,
remaining_duration: Duration::days(5),
percent_complete: 60,
status: TaskStatus::InProgress,
cost_range: None,
has_abstract_assignments: false,
baseline_start: start1,
baseline_finish: finish1,
start_variance_days: 0,
finish_variance_days: 0,
},
);
let schedule = Schedule {
tasks,
critical_path: vec![],
project_duration: Duration::days(5),
project_end: finish1,
total_cost: None,
total_cost_range: None,
project_progress: 0,
project_baseline_finish: finish1,
project_forecast_finish: finish1,
project_variance_days: 0,
planned_value: 0,
earned_value: 0,
spi: 1.0,
};
let renderer = PlantUmlRenderer::new().no_aliases();
let output = renderer.render(&project, &schedule).unwrap();
assert!(output.contains("[Work Task] is 60% complete"));
}
}