nigig-org/crates/apps/nigig-build/tests/project_management.rs
2026-07-26 19:38:26 +03:00

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);
}