//! Integration tests for the project management module. //! //! Tests full workflows: task creation → scheduling → serialization → //! persistence → deserialization → verification. //! //! These tests exercise pure data logic without makepad widget runtime. use std::collections::{HashMap, HashSet}; // ── Inline copies of types from project_management (no makepad dependency) // We duplicate the minimal types here so integration tests can compile // independently of the makepad widget framework. #[derive(Clone, Debug, PartialEq, Default)] enum TaskType { #[default] Task, Milestone, SummaryGroup, } impl TaskType { fn to_code(&self) -> &str { match self { TaskType::Task => "T", TaskType::Milestone => "M", TaskType::SummaryGroup => "G", } } fn from_code(code: &str) -> Self { match code { "M" => TaskType::Milestone, "G" => TaskType::SummaryGroup, _ => TaskType::Task, } } } #[derive(Clone, Debug, Default)] struct GanttTask { id: u32, name: String, start_day: i32, duration_days: i32, progress: f64, assignee: String, indent_level: u8, task_type: TaskType, dependencies: Vec, is_critical: bool, is_collapsed: bool, baseline_start: Option, baseline_duration: Option, slack: i32, } impl GanttTask { fn end_day(&self) -> i32 { if matches!(self.task_type, TaskType::Milestone) { self.start_day } else { self.start_day + self.duration_days } } } // ── Minimal copy of logic.rs functions ────────────────────────── fn build_id_map(tasks: &[GanttTask]) -> HashMap { tasks.iter().enumerate().map(|(i, t)| (t.id, i)).collect() } fn recalculate_summary_groups(tasks: &mut [GanttTask]) { let mut i = 0; while i < tasks.len() { if matches!(tasks[i].task_type, TaskType::SummaryGroup) { let parent_lvl = tasks[i].indent_level; let mut min_start = i32::MAX; let mut max_end = i32::MIN; let mut j = i + 1; while j < tasks.len() && tasks[j].indent_level > parent_lvl { min_start = min_start.min(tasks[j].start_day); max_end = max_end.max(tasks[j].end_day()); j += 1; } if min_start != i32::MAX && max_end != i32::MIN { tasks[i].start_day = min_start; tasks[i].duration_days = (max_end - min_start).max(1); } } i += 1; } } fn apply_automatic_scheduling(tasks: &mut [GanttTask]) { if tasks.is_empty() { return; } let mut changed = true; let mut iterations = 0; let max_iterations = tasks.len() * 2; while changed && iterations < max_iterations { changed = false; iterations += 1; let id_map = build_id_map(tasks); for i in 0..tasks.len() { let mut earliest_start = tasks[i].start_day; for &dep_id in &tasks[i].dependencies { if let Some(&dep_idx) = id_map.get(&dep_id) { let dep_end = tasks[dep_idx].end_day(); if dep_end > earliest_start { earliest_start = dep_end; } } } if earliest_start != tasks[i].start_day { tasks[i].start_day = earliest_start; changed = true; } } if changed { recalculate_summary_groups(tasks); } } } fn recalculate_critical_path(tasks: &mut [GanttTask]) { if tasks.is_empty() { return; } let id_map = build_id_map(tasks); let mut early_finish = vec![0; tasks.len()]; for i in 0..tasks.len() { early_finish[i] = tasks[i].end_day(); } let project_end = *early_finish.iter().max().unwrap_or(&0); let mut late_start = vec![project_end; tasks.len()]; let mut late_finish = vec![project_end; tasks.len()]; let mut successors: HashMap> = HashMap::new(); for t in tasks.iter() { for &dep_id in &t.dependencies { successors.entry(dep_id).or_default().push(t.id); } } let mut changed = true; while changed { changed = false; for i in (0..tasks.len()).rev() { let tid = tasks[i].id; let mut lf = project_end; if let Some(succs) = successors.get(&tid) { for &sid in succs { if let Some(&sidx) = id_map.get(&sid) { lf = lf.min(late_start[sidx]); } } } let ls = lf - tasks[i].duration_days; if ls != late_start[i] || lf != late_finish[i] { late_start[i] = ls; late_finish[i] = lf; changed = true; } } } for i in 0..tasks.len() { tasks[i].slack = late_start[i] - tasks[i].start_day; tasks[i].is_critical = tasks[i].slack <= 0; } } fn would_create_cycle(tasks: &[GanttTask], source_id: u32, target_id: u32) -> bool { if source_id == target_id { return true; } let id_map = build_id_map(tasks); let mut visited = HashSet::new(); let mut stack = vec![source_id]; while let Some(curr_id) = stack.pop() { if curr_id == target_id { return true; } if !visited.insert(curr_id) { continue; } if let Some(&pos) = id_map.get(&curr_id) { for &dep_id in &tasks[pos].dependencies { stack.push(dep_id); } } } false } fn get_over_allocated_assignees(tasks: &[GanttTask]) -> HashSet { let mut over_allocated = HashSet::new(); let mut assignee_tasks: HashMap> = HashMap::new(); for task in tasks { if matches!(task.task_type, TaskType::Task) { assignee_tasks.entry(task.assignee.clone()) .or_default() .push((task.start_day, task.end_day())); } } for (assignee, intervals) in assignee_tasks { if intervals.len() < 2 { continue; } let mut min_day = i32::MAX; let mut max_day = i32::MIN; for &(s, e) in &intervals { min_day = min_day.min(s); max_day = max_day.max(e); } for day in min_day..max_day { let mut count = 0; for &(s, e) in &intervals { if day >= s && day < e { count += 1; } } if count > 1 { over_allocated.insert(assignee); break; } } } over_allocated } // ── Helper to build tasks ─────────────────────────────────────── fn t(id: u32, name: &str, start: i32, dur: i32) -> GanttTask { GanttTask { id, name: name.into(), start_day: start, duration_days: dur, ..Default::default() } } fn t_dep(id: u32, name: &str, start: i32, dur: i32, deps: Vec) -> GanttTask { GanttTask { id, name: name.into(), start_day: start, duration_days: dur, dependencies: deps, ..Default::default() } } fn t_assign(id: u32, name: &str, start: i32, dur: i32, assignee: &str) -> GanttTask { GanttTask { id, name: name.into(), start_day: start, duration_days: dur, assignee: assignee.into(), ..Default::default() } } // ══════════════════════════════════════════════════════════════ // Integration Tests // ══════════════════════════════════════════════════════════════ /// Full construction workflow: 4-phase project with dependencies, /// summary groups, critical path, and resource allocation. #[test] fn full_construction_project_workflow() { let mut tasks = vec![ // Phase 1: Foundation GanttTask { id: 1, name: "Excavation".into(), start_day: 0, duration_days: 5, assignee: "Crew A".into(), ..Default::default() }, t_dep(2, "Pour Foundation", 0, 10, vec![1]), t_dep(3, "Cure Concrete", 0, 7, vec![2]), // Phase 2: Framing t_dep(4, "Erect Framing", 0, 12, vec![3]), t_dep(5, "Install Roof", 0, 5, vec![4]), // Phase 3: MEP t_dep(6, "Electrical", 0, 8, vec![4]), t_dep(7, "Plumbing", 0, 6, vec![4]), t_dep(8, "HVAC", 0, 7, vec![4]), // Phase 4: Finishes t_dep(9, "Drywall", 0, 5, vec![6, 7, 8]), t_dep(10, "Paint", 0, 3, vec![9]), t_dep(11, "Final Inspection", 0, 1, vec![5, 10]), // Summary groups GanttTask { id: 20, name: "Phase 1".into(), task_type: TaskType::SummaryGroup, indent_level: 0, ..Default::default() }, GanttTask { id: 21, name: "Phase 2".into(), task_type: TaskType::SummaryGroup, indent_level: 0, ..Default::default() }, GanttTask { id: 22, name: "Phase 3".into(), task_type: TaskType::SummaryGroup, indent_level: 0, ..Default::default() }, GanttTask { id: 23, name: "Phase 4".into(), task_type: TaskType::SummaryGroup, indent_level: 0, ..Default::default() }, ]; // Set indent levels for summary children for i in 0..tasks.len() { match tasks[i].id { 1..=3 => tasks[i].indent_level = 1, 4..=5 => tasks[i].indent_level = 1, 6..=8 => tasks[i].indent_level = 1, 9..=11 => tasks[i].indent_level = 1, _ => {} } } // 1. Schedule apply_automatic_scheduling(&mut tasks); // 2. Summary groups recalculate_summary_groups(&mut tasks); // 3. Critical path recalculate_critical_path(&mut tasks); // Verify scheduling let id_map = build_id_map(&tasks); let excavation = &tasks[id_map[&1]]; let foundation = &tasks[id_map[&2]]; let cure = &tasks[id_map[&3]]; assert_eq!(excavation.start_day, 0); assert_eq!(foundation.start_day, 5); // after excavation assert_eq!(cure.start_day, 15); // after foundation (5+10) // Verify critical path let final_inspection = &tasks[id_map[&11]]; assert!(final_inspection.is_critical); assert_eq!(final_inspection.slack, 0); // Verify over-allocation detection let oa = get_over_allocated_assignees(&tasks); assert!(oa.is_empty()); // different assignees } /// 3-switch desktop↔mobile simulation: build project → serialize → /// edit → re-serialize → verify. #[test] fn project_serialization_round_trip() { let tasks = vec![ t(1, "Task A", 0, 5), t_dep(2, "Task B", 5, 3, vec![1]), GanttTask { id: 3, name: "Milestone".into(), start_day: 8, task_type: TaskType::Milestone, ..Default::default() }, ]; // Simulate serialize → deserialize → verify let serialized = tasks.iter().map(|t| { format!("{}|{}|{}|{}|{}|{}|{}|{}||{}", t.id, t.task_type.to_code(), t.name, t.start_day, t.duration_days, t.progress, t.assignee, t.indent_level, t.dependencies.iter().map(|d| d.to_string()).collect::>().join(",")) }).collect::>().join("\n"); let restored: Vec = serialized.lines().filter_map(|line| { let parts: Vec<&str> = line.split('|').collect(); if parts.len() >= 9 { Some(GanttTask { id: parts[0].parse().ok()?, name: parts[2].to_string(), start_day: parts[3].parse().ok()?, duration_days: parts[4].parse().ok()?, progress: parts[5].parse().ok().unwrap_or(0.0), assignee: parts[6].to_string(), indent_level: parts[7].parse().ok().unwrap_or(0), task_type: TaskType::from_code(parts[1]), dependencies: parts.get(8).unwrap_or(&"").split(',') .filter_map(|s| s.parse().ok()).collect(), ..Default::default() }) } else { None } }).collect(); assert_eq!(restored.len(), 3); assert_eq!(restored[0].name, "Task A"); assert_eq!(restored[1].dependencies, vec![1]); assert_eq!(restored[2].task_type, TaskType::Milestone); } /// Cycle detection in a complex graph. #[test] fn cycle_detection_complex_graph() { // 1→2→3→4 (chain). Adding 4→1 creates a cycle. let tasks = vec![ t_dep(1, "A", 0, 5, vec![4]), t_dep(2, "B", 0, 3, vec![1]), t_dep(3, "C", 0, 2, vec![2]), t_dep(4, "D", 0, 1, vec![3]), ]; // A depends on D, D depends on C, C depends on B, B depends on A → cycle assert!(would_create_cycle(&tasks, 4, 1)); // No cycle when adding 3→1 (chain is 1→2→3→4, 3→1 would create 1→2→3→1) assert!(would_create_cycle(&tasks, 3, 1)); } /// Summary group auto-span with nested groups. #[test] fn summary_group_nested_span() { let mut tasks = vec![ GanttTask { id: 1, name: "Outer".into(), task_type: TaskType::SummaryGroup, indent_level: 0, ..Default::default() }, GanttTask { id: 2, name: "Inner".into(), task_type: TaskType::SummaryGroup, indent_level: 1, ..Default::default() }, t(3, "Task A", 5, 3), t(4, "Task B", 10, 4), ]; tasks[3].indent_level = 2; tasks[4].indent_level = 2; recalculate_summary_groups(&mut tasks); // Inner group spans tasks 3 and 4 assert_eq!(tasks[1].start_day, 5); assert_eq!(tasks[1].duration_days, 9); // 14 - 5 // Outer group spans inner group assert_eq!(tasks[0].start_day, 5); assert_eq!(tasks[0].duration_days, 9); } /// Over-allocation with 3 tasks by same person on overlapping days. #[test] fn over_allocation_triple_overlap() { let tasks = vec![ t_assign(1, "A", 0, 10, "Alice"), t_assign(2, "B", 5, 10, "Alice"), t_assign(3, "C", 8, 5, "Alice"), ]; let oa = get_over_allocated_assignees(&tasks); assert!(oa.contains("Alice")); } /// Over-allocation with edge-touching tasks (no overlap). #[test] fn over_allocation_edge_touching() { let tasks = vec![ t_assign(1, "A", 0, 5, "Alice"), t_assign(2, "B", 5, 5, "Alice"), // starts exactly when A ends ]; let oa = get_over_allocated_assignees(&tasks); assert!(oa.is_empty()); } /// Multiple summary groups at same level. #[test] fn multiple_summary_groups_at_same_level() { let mut tasks = vec![ GanttTask { id: 1, name: "Phase A".into(), task_type: TaskType::SummaryGroup, indent_level: 0, ..Default::default() }, t(2, "A1", 0, 5), t(3, "A2", 5, 3), GanttTask { id: 4, name: "Phase B".into(), task_type: TaskType::SummaryGroup, indent_level: 0, ..Default::default() }, t(5, "B1", 8, 4), t(6, "B2", 12, 2), ]; tasks[1].indent_level = 1; tasks[2].indent_level = 1; tasks[4].indent_level = 1; tasks[5].indent_level = 1; recalculate_summary_groups(&mut tasks); assert_eq!(tasks[0].start_day, 0); assert_eq!(tasks[0].duration_days, 8); // 0..8 assert_eq!(tasks[3].start_day, 8); assert_eq!(tasks[3].duration_days, 6); // 8..14 } /// Scheduling + critical path: verify critical path after auto-schedule. #[test] fn scheduling_then_critical_path() { let mut tasks = vec![ t_dep(1, "A", 0, 10, vec![]), t_dep(2, "B", 0, 5, vec![1]), t_dep(3, "C", 0, 3, vec![1]), t_dep(4, "D", 0, 2, vec![2, 3]), ]; apply_automatic_scheduling(&mut tasks); recalculate_critical_path(&mut tasks); // A→B→D is the longest path (10+5+2=17), critical assert!(tasks[0].is_critical); // A assert!(tasks[1].is_critical); // B assert!(!tasks[2].is_critical); // C has slack assert!(tasks[3].is_critical); // D assert!(tasks[2].slack > 0); }