466 lines
16 KiB
Rust
466 lines
16 KiB
Rust
//! 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<u32>,
|
|
is_critical: bool,
|
|
is_collapsed: bool,
|
|
baseline_start: Option<i32>,
|
|
baseline_duration: Option<i32>,
|
|
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<u32, usize> {
|
|
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<u32, Vec<u32>> = 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<String> {
|
|
let mut over_allocated = HashSet::new();
|
|
let mut assignee_tasks: HashMap<String, Vec<(i32, i32)>> = 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<u32>) -> 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::<Vec<_>>().join(","))
|
|
}).collect::<Vec<_>>().join("\n");
|
|
|
|
let restored: Vec<GanttTask> = 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);
|
|
}
|