1pub mod excel;
43pub mod gantt;
44pub mod mermaid;
45pub mod plantuml;
46
47pub use excel::{ExcelConfig, ExcelRenderer, ScheduleGranularity};
48pub use gantt::{FocusConfig, GanttTheme, HtmlGanttRenderer, TaskVisibility};
49pub use mermaid::MermaidRenderer;
50pub use plantuml::PlantUmlRenderer;
51
52use chrono::NaiveDate;
53use svg::node::element::{Group, Line, Rectangle, Text};
54use svg::Document;
55use utf8proj_core::{Project, RenderError, Renderer, Schedule, ScheduledTask};
56
57#[derive(Clone, Copy, Debug, Default, PartialEq)]
59pub enum DisplayMode {
60 #[default]
62 Name,
63 Id,
65 Verbose,
67}
68
69impl DisplayMode {
70 pub fn format_label(&self, task_id: &str, task_name: &str, max_width: usize) -> String {
72 let label = match self {
73 DisplayMode::Name => {
74 if task_name.is_empty() || task_name == task_id {
75 task_id.to_string()
76 } else {
77 task_name.to_string()
78 }
79 }
80 DisplayMode::Id => task_id.to_string(),
81 DisplayMode::Verbose => {
82 if task_name.is_empty() || task_name == task_id {
83 format!("[{}]", task_id)
84 } else {
85 format!("[{}] {}", task_id, task_name)
86 }
87 }
88 };
89
90 if label.len() > max_width && max_width > 3 {
92 format!("{}...", &label[..max_width - 3])
93 } else {
94 label
95 }
96 }
97}
98
99#[derive(Clone, Debug)]
101pub struct SvgRenderer {
102 pub chart_width: u32,
104 pub row_height: u32,
106 pub label_width: u32,
108 pub header_height: u32,
110 pub padding: u32,
112 pub critical_color: String,
114 pub normal_color: String,
116 pub milestone_color: String,
118 pub background_color: String,
120 pub grid_color: String,
122 pub text_color: String,
124 pub font_family: String,
126 pub font_size: u32,
128 pub display_mode: DisplayMode,
130}
131
132impl Default for SvgRenderer {
133 fn default() -> Self {
134 Self {
135 chart_width: 800,
136 row_height: 28,
137 label_width: 180,
138 header_height: 50,
139 padding: 20,
140 critical_color: "#e74c3c".into(),
141 normal_color: "#3498db".into(),
142 milestone_color: "#9b59b6".into(),
143 background_color: "#ffffff".into(),
144 grid_color: "#ecf0f1".into(),
145 text_color: "#2c3e50".into(),
146 font_family: "system-ui, -apple-system, sans-serif".into(),
147 font_size: 12,
148 display_mode: DisplayMode::Name,
149 }
150 }
151}
152
153impl SvgRenderer {
154 pub fn new() -> Self {
155 Self::default()
156 }
157
158 pub fn chart_width(mut self, width: u32) -> Self {
160 self.chart_width = width;
161 self
162 }
163
164 pub fn row_height(mut self, height: u32) -> Self {
166 self.row_height = height;
167 self
168 }
169
170 pub fn label_width(mut self, width: u32) -> Self {
172 self.label_width = width;
173 self
174 }
175
176 pub fn display_mode(mut self, mode: DisplayMode) -> Self {
178 self.display_mode = mode;
179 self
180 }
181
182 fn total_width(&self) -> u32 {
184 self.padding * 2 + self.label_width + self.chart_width
185 }
186
187 fn total_height(&self, task_count: usize) -> u32 {
189 self.padding * 2 + self.header_height + (task_count as u32 * self.row_height)
190 }
191
192 fn pixels_per_day(&self, start: NaiveDate, end: NaiveDate) -> f64 {
194 let days = (end - start).num_days().max(1) as f64;
195 self.chart_width as f64 / days
196 }
197
198 fn date_to_x(&self, date: NaiveDate, project_start: NaiveDate, px_per_day: f64) -> f64 {
200 let days = (date - project_start).num_days() as f64;
201 self.padding as f64 + self.label_width as f64 + (days * px_per_day)
202 }
203
204 fn render_header(
206 &self,
207 project_start: NaiveDate,
208 project_end: NaiveDate,
209 px_per_day: f64,
210 ) -> Group {
211 let mut group = Group::new().set("class", "header");
212
213 let header_bg = Rectangle::new()
215 .set("x", self.padding)
216 .set("y", self.padding)
217 .set("width", self.label_width + self.chart_width)
218 .set("height", self.header_height)
219 .set("fill", "#f8f9fa");
220 group = group.add(header_bg);
221
222 let total_days = (project_end - project_start).num_days();
224 let interval_days = if total_days <= 14 {
225 1 } else if total_days <= 60 {
227 7 } else if total_days <= 180 {
229 14 } else {
231 30 };
233
234 let mut current = project_start;
236 while current <= project_end {
237 let x = self.date_to_x(current, project_start, px_per_day);
238
239 let line = Line::new()
241 .set("x1", x)
242 .set("y1", self.padding + self.header_height - 10)
243 .set("x2", x)
244 .set("y2", self.padding + self.header_height)
245 .set("stroke", self.text_color.as_str())
246 .set("stroke-width", 1);
247 group = group.add(line);
248
249 let label = if interval_days == 1 {
251 current.format("%d").to_string()
252 } else if interval_days <= 7 {
253 current.format("%b %d").to_string()
254 } else {
255 current.format("%b %d").to_string()
256 };
257
258 let text = Text::new(label)
259 .set("x", x)
260 .set("y", self.padding + self.header_height - 15)
261 .set("font-family", self.font_family.as_str())
262 .set("font-size", self.font_size - 1)
263 .set("fill", self.text_color.as_str())
264 .set("text-anchor", "middle");
265 group = group.add(text);
266
267 current += chrono::Duration::days(interval_days);
268 }
269
270 let month_label = project_start.format("%B %Y").to_string();
272 let month_text = Text::new(month_label)
273 .set("x", self.padding + self.label_width + self.chart_width / 2)
274 .set("y", self.padding + 18)
275 .set("font-family", self.font_family.as_str())
276 .set("font-size", self.font_size + 2)
277 .set("font-weight", "bold")
278 .set("fill", self.text_color.as_str())
279 .set("text-anchor", "middle");
280 group = group.add(month_text);
281
282 group
283 }
284
285 fn render_grid(
287 &self,
288 task_count: usize,
289 project_start: NaiveDate,
290 project_end: NaiveDate,
291 px_per_day: f64,
292 ) -> Group {
293 let mut group = Group::new().set("class", "grid");
294
295 let chart_top = self.padding + self.header_height;
296 let chart_bottom = chart_top + (task_count as u32 * self.row_height);
297
298 for i in 0..=task_count {
300 let y = chart_top + (i as u32 * self.row_height);
301 let line = Line::new()
302 .set("x1", self.padding)
303 .set("y1", y)
304 .set("x2", self.padding + self.label_width + self.chart_width)
305 .set("y2", y)
306 .set("stroke", self.grid_color.as_str())
307 .set("stroke-width", 1);
308 group = group.add(line);
309 }
310
311 let total_days = (project_end - project_start).num_days();
313 let interval = if total_days <= 30 { 1 } else { 7 };
314
315 let mut current = project_start;
316 while current <= project_end {
317 let x = self.date_to_x(current, project_start, px_per_day);
318 let line = Line::new()
319 .set("x1", x)
320 .set("y1", chart_top)
321 .set("x2", x)
322 .set("y2", chart_bottom)
323 .set("stroke", self.grid_color.as_str())
324 .set("stroke-width", 1);
325 group = group.add(line);
326 current += chrono::Duration::days(interval);
327 }
328
329 group
330 }
331
332 fn render_task(
334 &self,
335 task: &ScheduledTask,
336 task_name: &str,
337 row: usize,
338 project_start: NaiveDate,
339 px_per_day: f64,
340 ) -> Group {
341 let mut group = Group::new().set("class", "task");
342
343 let y = self.padding + self.header_height + (row as u32 * self.row_height);
344 let bar_height = (self.row_height as f64 * 0.6) as u32;
345 let bar_y = y + (self.row_height - bar_height) / 2;
346
347 let label = Text::new(task_name)
349 .set("x", self.padding + 8)
350 .set("y", y + self.row_height / 2 + 4)
351 .set("font-family", self.font_family.as_str())
352 .set("font-size", self.font_size)
353 .set("fill", self.text_color.as_str());
354 group = group.add(label);
355
356 let x_start = self.date_to_x(task.start, project_start, px_per_day);
358 let x_end = self.date_to_x(task.finish, project_start, px_per_day);
359 let bar_width = (x_end - x_start).max(4.0); let is_milestone = task.duration.minutes == 0;
363
364 if is_milestone {
365 let cx = x_start;
367 let cy = (bar_y + bar_height / 2) as f64;
368 let size = (bar_height as f64) / 2.0;
369
370 let diamond = svg::node::element::Polygon::new()
371 .set(
372 "points",
373 format!(
374 "{},{} {},{} {},{} {},{}",
375 cx,
376 cy - size,
377 cx + size,
378 cy,
379 cx,
380 cy + size,
381 cx - size,
382 cy
383 ),
384 )
385 .set("fill", self.milestone_color.as_str());
386 group = group.add(diamond);
387 } else {
388 let color = if task.is_critical {
390 self.critical_color.as_str()
391 } else {
392 self.normal_color.as_str()
393 };
394
395 let bar = Rectangle::new()
396 .set("x", x_start)
397 .set("y", bar_y)
398 .set("width", bar_width)
399 .set("height", bar_height)
400 .set("rx", 3)
401 .set("ry", 3)
402 .set("fill", color);
403 group = group.add(bar);
404
405 let highlight = Rectangle::new()
407 .set("x", x_start)
408 .set("y", bar_y)
409 .set("width", bar_width)
410 .set("height", bar_height / 3)
411 .set("rx", 3)
412 .set("ry", 3)
413 .set("fill", "rgba(255,255,255,0.2)");
414 group = group.add(highlight);
415 }
416
417 group
418 }
419
420 fn render_legend(&self, y_offset: u32) -> Group {
422 let mut group = Group::new().set("class", "legend");
423 let x_start = self.padding as f64;
424 let y = y_offset as f64 + 15.0;
425 let box_size = 12.0;
426 let spacing = 120.0;
427
428 let critical_box = Rectangle::new()
430 .set("x", x_start)
431 .set("y", y - box_size + 2.0)
432 .set("width", box_size)
433 .set("height", box_size)
434 .set("rx", 2)
435 .set("fill", self.critical_color.as_str());
436 group = group.add(critical_box);
437
438 let critical_label = Text::new("Critical Path")
439 .set("x", x_start + box_size + 5.0)
440 .set("y", y)
441 .set("font-family", self.font_family.as_str())
442 .set("font-size", self.font_size - 1)
443 .set("fill", self.text_color.as_str());
444 group = group.add(critical_label);
445
446 let normal_box = Rectangle::new()
448 .set("x", x_start + spacing)
449 .set("y", y - box_size + 2.0)
450 .set("width", box_size)
451 .set("height", box_size)
452 .set("rx", 2)
453 .set("fill", self.normal_color.as_str());
454 group = group.add(normal_box);
455
456 let normal_label = Text::new("Normal Task")
457 .set("x", x_start + spacing + box_size + 5.0)
458 .set("y", y)
459 .set("font-family", self.font_family.as_str())
460 .set("font-size", self.font_size - 1)
461 .set("fill", self.text_color.as_str());
462 group = group.add(normal_label);
463
464 let mx = x_start + spacing * 2.0 + box_size / 2.0;
466 let my = y - box_size / 2.0 + 2.0;
467 let msize = box_size / 2.0;
468
469 let milestone = svg::node::element::Polygon::new()
470 .set(
471 "points",
472 format!(
473 "{},{} {},{} {},{} {},{}",
474 mx,
475 my - msize,
476 mx + msize,
477 my,
478 mx,
479 my + msize,
480 mx - msize,
481 my
482 ),
483 )
484 .set("fill", self.milestone_color.as_str());
485 group = group.add(milestone);
486
487 let milestone_label = Text::new("Milestone")
488 .set("x", x_start + spacing * 2.0 + box_size + 5.0)
489 .set("y", y)
490 .set("font-family", self.font_family.as_str())
491 .set("font-size", self.font_size - 1)
492 .set("fill", self.text_color.as_str());
493 group = group.add(milestone_label);
494
495 group
496 }
497}
498
499impl Renderer for SvgRenderer {
500 type Output = String;
501
502 fn render(&self, project: &Project, schedule: &Schedule) -> Result<String, RenderError> {
503 let mut tasks: Vec<&ScheduledTask> = schedule.tasks.values().collect();
505 tasks.sort_by_key(|t| t.start);
506
507 if tasks.is_empty() {
508 return Err(RenderError::InvalidData("No tasks to render".into()));
509 }
510
511 let task_count = tasks.len();
512 let project_start = project.start;
513 let project_end = schedule.project_end;
514 let px_per_day = self.pixels_per_day(project_start, project_end);
515
516 let width = self.total_width();
518 let height = self.total_height(task_count) + 30; let mut document = Document::new()
522 .set("width", width)
523 .set("height", height)
524 .set("viewBox", (0, 0, width, height))
525 .set("xmlns", "http://www.w3.org/2000/svg");
526
527 let background = Rectangle::new()
529 .set("width", "100%")
530 .set("height", "100%")
531 .set("fill", self.background_color.as_str());
532 document = document.add(background);
533
534 let title = Text::new(project.name.as_str())
536 .set("x", self.padding)
537 .set("y", self.padding + 15)
538 .set("font-family", self.font_family.as_str())
539 .set("font-size", self.font_size + 4)
540 .set("font-weight", "bold")
541 .set("fill", self.text_color.as_str());
542 document = document.add(title);
543
544 document =
546 document.add(self.render_grid(task_count, project_start, project_end, px_per_day));
547
548 document = document.add(self.render_header(project_start, project_end, px_per_day));
550
551 let max_chars = (self.label_width as usize / 8).max(10);
554
555 for (row, scheduled_task) in tasks.iter().enumerate() {
556 let task_name = project
558 .get_task(&scheduled_task.task_id)
559 .map(|t| t.name.as_str())
560 .unwrap_or(&scheduled_task.task_id);
561
562 let label =
564 self.display_mode
565 .format_label(&scheduled_task.task_id, task_name, max_chars);
566
567 document = document.add(self.render_task(
568 scheduled_task,
569 &label,
570 row,
571 project_start,
572 px_per_day,
573 ));
574 }
575
576 let legend_y =
578 self.padding + self.header_height + (task_count as u32 * self.row_height) + 10;
579 document = document.add(self.render_legend(legend_y));
580
581 let mut output = Vec::new();
583 svg::write(&mut output, &document)
584 .map_err(|e| RenderError::Format(format!("Failed to write SVG: {}", e)))?;
585
586 String::from_utf8(output).map_err(|e| RenderError::Format(format!("Invalid UTF-8: {}", e)))
587 }
588}
589
590#[derive(Default)]
592pub struct TextRenderer;
593
594impl Renderer for TextRenderer {
595 type Output = String;
596
597 fn render(&self, project: &Project, _schedule: &Schedule) -> Result<String, RenderError> {
598 Ok(format!("Project: {}\n", project.name))
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use chrono::NaiveDate;
606 use std::collections::HashMap;
607 use utf8proj_core::{Duration, Schedule, ScheduledTask, TaskStatus};
608
609 fn create_test_project() -> Project {
610 let mut project = Project::new("Test Project");
611 project.start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
612 project.tasks.push(
613 utf8proj_core::Task::new("task1")
614 .name("Design Phase")
615 .duration(Duration::days(3)),
616 );
617 project.tasks.push(
618 utf8proj_core::Task::new("task2")
619 .name("Implementation")
620 .duration(Duration::days(5))
621 .depends_on("task1"),
622 );
623 project
624 }
625
626 fn create_test_schedule() -> Schedule {
627 let mut tasks = HashMap::new();
628
629 let start1 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
630 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
631 tasks.insert(
632 "task1".to_string(),
633 ScheduledTask {
634 task_id: "task1".to_string(),
635 start: start1,
636 finish: finish1,
637 duration: Duration::days(3),
638 assignments: vec![],
639 slack: Duration::zero(),
640 is_critical: true,
641 early_start: start1,
642 early_finish: finish1,
643 late_start: start1,
644 late_finish: finish1,
645 forecast_start: start1,
646 forecast_finish: finish1,
647 remaining_duration: Duration::days(3),
648 percent_complete: 0,
649 status: TaskStatus::NotStarted,
650 cost_range: None,
651 has_abstract_assignments: false,
652 baseline_start: start1,
653 baseline_finish: finish1,
654 start_variance_days: 0,
655 finish_variance_days: 0,
656 },
657 );
658
659 let start2 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
660 let finish2 = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
661 tasks.insert(
662 "task2".to_string(),
663 ScheduledTask {
664 task_id: "task2".to_string(),
665 start: start2,
666 finish: finish2,
667 duration: Duration::days(5),
668 assignments: vec![],
669 slack: Duration::zero(),
670 is_critical: true,
671 early_start: start2,
672 early_finish: finish2,
673 late_start: start2,
674 late_finish: finish2,
675 forecast_start: start2,
676 forecast_finish: finish2,
677 remaining_duration: Duration::days(5),
678 percent_complete: 0,
679 status: TaskStatus::NotStarted,
680 cost_range: None,
681 has_abstract_assignments: false,
682 baseline_start: start2,
683 baseline_finish: finish2,
684 start_variance_days: 0,
685 finish_variance_days: 0,
686 },
687 );
688
689 let project_end = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
690 Schedule {
691 tasks,
692 critical_path: vec!["task1".to_string(), "task2".to_string()],
693 project_duration: Duration::days(8),
694 project_end,
695 total_cost: None,
696 total_cost_range: None,
697 project_progress: 0,
698 project_baseline_finish: project_end,
699 project_forecast_finish: project_end,
700 project_variance_days: 0,
701 planned_value: 0,
702 earned_value: 0,
703 spi: 1.0,
704 }
705 }
706
707 #[test]
708 fn svg_renderer_creation() {
709 let renderer = SvgRenderer::new();
710 assert_eq!(renderer.chart_width, 800);
711 assert_eq!(renderer.row_height, 28);
712 }
713
714 #[test]
715 fn svg_renderer_with_config() {
716 let renderer = SvgRenderer::new().chart_width(1000).row_height(40);
717 assert_eq!(renderer.chart_width, 1000);
718 assert_eq!(renderer.row_height, 40);
719 }
720
721 #[test]
722 fn svg_render_produces_valid_svg() {
723 let renderer = SvgRenderer::new();
724 let project = create_test_project();
725 let schedule = create_test_schedule();
726
727 let result = renderer.render(&project, &schedule);
728 assert!(result.is_ok());
729
730 let svg = result.unwrap();
731 assert!(svg.starts_with("<svg"));
732 assert!(svg.contains("</svg>"));
733 assert!(svg.contains("Test Project"));
734 assert!(svg.contains("Design Phase"));
735 }
736
737 #[test]
738 fn svg_render_includes_critical_path_styling() {
739 let renderer = SvgRenderer::new();
740 let project = create_test_project();
741 let schedule = create_test_schedule();
742
743 let svg = renderer.render(&project, &schedule).unwrap();
744 assert!(svg.contains(&renderer.critical_color));
745 }
746
747 #[test]
748 fn svg_render_empty_schedule_fails() {
749 let renderer = SvgRenderer::new();
750 let project = Project::new("Empty");
751 let project_end = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
752 let schedule = Schedule {
753 tasks: HashMap::new(),
754 critical_path: vec![],
755 project_duration: Duration::zero(),
756 project_end,
757 total_cost: None,
758 total_cost_range: None,
759 project_progress: 0,
760 project_baseline_finish: project_end,
761 project_forecast_finish: project_end,
762 project_variance_days: 0,
763 planned_value: 0,
764 earned_value: 0,
765 spi: 1.0,
766 };
767
768 let result = renderer.render(&project, &schedule);
769 assert!(result.is_err());
770 }
771
772 #[test]
773 fn text_renderer_basic() {
774 let renderer = TextRenderer;
775 let project = create_test_project();
776 let schedule = create_test_schedule();
777
778 let result = renderer.render(&project, &schedule);
779 assert!(result.is_ok());
780 let text = result.unwrap();
781 assert!(text.contains("Test Project"));
782 }
783
784 #[test]
785 fn svg_render_with_milestone() {
786 let mut project = Project::new("Milestone Test");
787 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
788 project.tasks.push(
789 utf8proj_core::Task::new("dev")
790 .name("Development")
791 .duration(Duration::days(5)),
792 );
793 project.tasks.push(
794 utf8proj_core::Task::new("release")
795 .name("Release")
796 .milestone()
797 .depends_on("dev"),
798 );
799
800 let mut tasks = HashMap::new();
801 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
802 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
803 tasks.insert(
804 "dev".to_string(),
805 ScheduledTask {
806 task_id: "dev".to_string(),
807 start: start1,
808 finish: finish1,
809 duration: Duration::days(5),
810 assignments: vec![],
811 slack: Duration::zero(),
812 is_critical: true,
813 early_start: start1,
814 early_finish: finish1,
815 late_start: start1,
816 late_finish: finish1,
817 forecast_start: start1,
818 forecast_finish: finish1,
819 remaining_duration: Duration::days(5),
820 percent_complete: 0,
821 status: TaskStatus::NotStarted,
822 cost_range: None,
823 has_abstract_assignments: false,
824 baseline_start: start1,
825 baseline_finish: finish1,
826 start_variance_days: 0,
827 finish_variance_days: 0,
828 },
829 );
830 let ms_date = NaiveDate::from_ymd_opt(2025, 1, 13).unwrap();
831 tasks.insert(
832 "release".to_string(),
833 ScheduledTask {
834 task_id: "release".to_string(),
835 start: ms_date,
836 finish: ms_date,
837 duration: Duration::zero(),
838 assignments: vec![],
839 slack: Duration::zero(),
840 is_critical: true,
841 early_start: ms_date,
842 early_finish: ms_date,
843 late_start: ms_date,
844 late_finish: ms_date,
845 forecast_start: ms_date,
846 forecast_finish: ms_date,
847 remaining_duration: Duration::zero(),
848 percent_complete: 0,
849 status: TaskStatus::NotStarted,
850 cost_range: None,
851 has_abstract_assignments: false,
852 baseline_start: ms_date,
853 baseline_finish: ms_date,
854 start_variance_days: 0,
855 finish_variance_days: 0,
856 },
857 );
858
859 let schedule = Schedule {
860 tasks,
861 critical_path: vec!["dev".to_string(), "release".to_string()],
862 project_duration: Duration::days(5),
863 project_end: ms_date,
864 total_cost: None,
865 total_cost_range: None,
866 project_progress: 0,
867 project_baseline_finish: ms_date,
868 project_forecast_finish: ms_date,
869 project_variance_days: 0,
870 planned_value: 0,
871 earned_value: 0,
872 spi: 1.0,
873 };
874
875 let renderer = SvgRenderer::new();
876 let svg = renderer.render(&project, &schedule).unwrap();
877
878 assert!(svg.contains("polygon"));
880 assert!(svg.contains("Release"));
881 }
882
883 #[test]
884 fn svg_render_non_critical_tasks() {
885 let mut project = Project::new("Non-Critical");
886 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
887 project.tasks.push(
888 utf8proj_core::Task::new("task1")
889 .name("Task 1")
890 .duration(Duration::days(5)),
891 );
892
893 let mut tasks = HashMap::new();
894 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
895 let finish1 = NaiveDate::from_ymd_opt(2025, 1, 10).unwrap();
896 tasks.insert(
897 "task1".to_string(),
898 ScheduledTask {
899 task_id: "task1".to_string(),
900 start: start1,
901 finish: finish1,
902 duration: Duration::days(5),
903 assignments: vec![],
904 slack: Duration::days(5), is_critical: false,
906 early_start: start1,
907 early_finish: finish1,
908 late_start: start1,
909 late_finish: finish1,
910 forecast_start: start1,
911 forecast_finish: finish1,
912 remaining_duration: Duration::days(5),
913 percent_complete: 0,
914 status: TaskStatus::NotStarted,
915 cost_range: None,
916 has_abstract_assignments: false,
917 baseline_start: start1,
918 baseline_finish: finish1,
919 start_variance_days: 0,
920 finish_variance_days: 0,
921 },
922 );
923
924 let schedule = Schedule {
925 tasks,
926 critical_path: vec![],
927 project_duration: Duration::days(5),
928 project_end: finish1,
929 total_cost: None,
930 total_cost_range: None,
931 project_progress: 0,
932 project_baseline_finish: finish1,
933 project_forecast_finish: finish1,
934 project_variance_days: 0,
935 planned_value: 0,
936 earned_value: 0,
937 spi: 1.0,
938 };
939
940 let renderer = SvgRenderer::new();
941 let svg = renderer.render(&project, &schedule).unwrap();
942
943 assert!(svg.contains(&renderer.normal_color));
945 }
946
947 #[test]
948 fn svg_render_long_project() {
949 let mut project = Project::new("Long Project");
951 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
952 project.tasks.push(
953 utf8proj_core::Task::new("phase1")
954 .name("Phase 1")
955 .duration(Duration::days(50)),
956 );
957 project.tasks.push(
958 utf8proj_core::Task::new("phase2")
959 .name("Phase 2")
960 .duration(Duration::days(50))
961 .depends_on("phase1"),
962 );
963
964 let mut tasks = HashMap::new();
965 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
966 let finish1 = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap(); tasks.insert(
968 "phase1".to_string(),
969 ScheduledTask {
970 task_id: "phase1".to_string(),
971 start: start1,
972 finish: finish1,
973 duration: Duration::days(50),
974 assignments: vec![],
975 slack: Duration::zero(),
976 is_critical: true,
977 early_start: start1,
978 early_finish: finish1,
979 late_start: start1,
980 late_finish: finish1,
981 forecast_start: start1,
982 forecast_finish: finish1,
983 remaining_duration: Duration::days(50),
984 percent_complete: 0,
985 status: TaskStatus::NotStarted,
986 cost_range: None,
987 has_abstract_assignments: false,
988 baseline_start: start1,
989 baseline_finish: finish1,
990 start_variance_days: 0,
991 finish_variance_days: 0,
992 },
993 );
994 let start2 = NaiveDate::from_ymd_opt(2025, 3, 17).unwrap();
995 let finish2 = NaiveDate::from_ymd_opt(2025, 5, 23).unwrap();
996 tasks.insert(
997 "phase2".to_string(),
998 ScheduledTask {
999 task_id: "phase2".to_string(),
1000 start: start2,
1001 finish: finish2,
1002 duration: Duration::days(50),
1003 assignments: vec![],
1004 slack: Duration::zero(),
1005 is_critical: true,
1006 early_start: start2,
1007 early_finish: finish2,
1008 late_start: start2,
1009 late_finish: finish2,
1010 forecast_start: start2,
1011 forecast_finish: finish2,
1012 remaining_duration: Duration::days(50),
1013 percent_complete: 0,
1014 status: TaskStatus::NotStarted,
1015 cost_range: None,
1016 has_abstract_assignments: false,
1017 baseline_start: start2,
1018 baseline_finish: finish2,
1019 start_variance_days: 0,
1020 finish_variance_days: 0,
1021 },
1022 );
1023
1024 let schedule = Schedule {
1025 tasks,
1026 critical_path: vec!["phase1".to_string(), "phase2".to_string()],
1027 project_duration: Duration::days(100),
1028 project_end: finish2,
1029 total_cost: None,
1030 total_cost_range: None,
1031 project_progress: 0,
1032 project_baseline_finish: finish2,
1033 project_forecast_finish: finish2,
1034 project_variance_days: 0,
1035 planned_value: 0,
1036 earned_value: 0,
1037 spi: 1.0,
1038 };
1039
1040 let renderer = SvgRenderer::new();
1041 let svg = renderer.render(&project, &schedule).unwrap();
1042
1043 assert!(svg.contains("Long Project"));
1044 assert!(svg.contains("Phase 1"));
1045 }
1046
1047 #[test]
1048 fn svg_render_very_long_project() {
1049 let mut project = Project::new("Very Long Project");
1051 project.start = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1052 project.tasks.push(
1053 utf8proj_core::Task::new("year")
1054 .name("Year Long Task")
1055 .duration(Duration::days(200)),
1056 );
1057
1058 let mut tasks = HashMap::new();
1059 let start1 = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap();
1060 let finish1 = NaiveDate::from_ymd_opt(2025, 10, 31).unwrap();
1061 tasks.insert(
1062 "year".to_string(),
1063 ScheduledTask {
1064 task_id: "year".to_string(),
1065 start: start1,
1066 finish: finish1,
1067 duration: Duration::days(200),
1068 assignments: vec![],
1069 slack: Duration::zero(),
1070 is_critical: true,
1071 early_start: start1,
1072 early_finish: finish1,
1073 late_start: start1,
1074 late_finish: finish1,
1075 forecast_start: start1,
1076 forecast_finish: finish1,
1077 remaining_duration: Duration::days(200),
1078 percent_complete: 0,
1079 status: TaskStatus::NotStarted,
1080 cost_range: None,
1081 has_abstract_assignments: false,
1082 baseline_start: start1,
1083 baseline_finish: finish1,
1084 start_variance_days: 0,
1085 finish_variance_days: 0,
1086 },
1087 );
1088
1089 let schedule = Schedule {
1090 tasks,
1091 critical_path: vec!["year".to_string()],
1092 project_duration: Duration::days(200),
1093 project_end: finish1,
1094 total_cost: None,
1095 total_cost_range: None,
1096 project_progress: 0,
1097 project_baseline_finish: finish1,
1098 project_forecast_finish: finish1,
1099 project_variance_days: 0,
1100 planned_value: 0,
1101 earned_value: 0,
1102 spi: 1.0,
1103 };
1104
1105 let renderer = SvgRenderer::new();
1106 let svg = renderer.render(&project, &schedule).unwrap();
1107
1108 assert!(svg.contains("Very Long Project"));
1109 }
1110}