1use std::collections::{HashMap, HashSet, VecDeque};
2
3use anyhow::{Context, bail};
4use bon::Builder;
5use chrono::{DateTime, Utc};
6use thiserror::Error;
7use uuid::Uuid;
8
9use crate::{
10 resources::{Material, Resource},
11 stakeholders::Stakeholder,
12 task::Task,
13};
14
15#[derive(Debug, Default, Builder)]
16#[builder(on(String, into))]
17pub struct Project {
19 name: String,
21 description: Option<String>,
23 start_date: Option<DateTime<Utc>>,
25 #[builder(default)]
27 tasks: HashMap<Uuid, Task>,
28 #[builder(default)]
30 task_order: Vec<Uuid>,
31 #[builder(default)]
33 succ: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
34 #[builder(default)]
36 pred: HashMap<Uuid, Vec<(Uuid, TimeRelationship)>>,
37 #[builder(default)]
39 children: HashMap<Uuid, Vec<Uuid>>,
40 #[builder(default)]
42 parent_of: HashMap<Uuid, Uuid>,
43 #[builder(default)]
45 resources: Vec<Resource>,
46 #[builder(default)]
48 stakeholders: Vec<Stakeholder>,
49}
50
51#[derive(Debug, Default, Clone, Copy)]
52pub enum TimeRelationship {
54 StartToFinish,
56 FinishToFinish,
58 #[default]
59 FinishToStart,
61 StartToStart,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum RelDir {
68 Predecessors,
70 Successors,
72}
73
74impl Project {
75 #[must_use]
94 pub fn new(name: impl Into<String>) -> Self {
95 Self {
96 name: name.into(),
97 ..Default::default()
98 }
99 }
100
101 #[must_use]
112 pub fn name(&self) -> &str {
113 &self.name
114 }
115
116 #[must_use]
127 pub fn description(&self) -> Option<&str> {
128 self.description.as_deref()
129 }
130
131 pub fn add_task(&mut self, task: Task) -> Uuid {
151 let id = task.id();
152 self.tasks.insert(id, task);
153 self.task_order.push(id);
154 id
155 }
156
157 pub fn add_sibling_before(&mut self, task: Task, sibling_id: Uuid) -> Uuid {
174 let id = task.id();
175 self.tasks.insert(id, task);
176 if let Some(pos) = self.task_order.iter().position(|&t| t == sibling_id) {
177 self.task_order.insert(pos, id);
178 } else {
179 self.task_order.push(id);
180 }
181 if let Some(&parent_id) = self.parent_of.get(&sibling_id) {
182 self.parent_of.insert(id, parent_id);
183 self.children.entry(parent_id).or_default().push(id);
184 }
185 id
186 }
187
188 pub fn add_sibling_after(&mut self, task: Task, sibling_id: Uuid) -> Uuid {
205 let id = task.id();
206 self.tasks.insert(id, task);
207 if let Some(pos) = self.task_order.iter().position(|&t| t == sibling_id) {
208 self.task_order.insert(pos + 1, id);
209 } else {
210 self.task_order.push(id);
211 }
212 if let Some(&parent_id) = self.parent_of.get(&sibling_id) {
213 self.parent_of.insert(id, parent_id);
214 self.children.entry(parent_id).or_default().push(id);
215 }
216 id
217 }
218
219 pub fn rm_task(&mut self, id: Uuid) -> anyhow::Result<Task> {
240 let task = self
241 .tasks
242 .remove(&id)
243 .context("Tried removing a non existing task")?;
244 self.task_order.retain(|t| *t != id);
245
246 for (succ, _) in self.succ.remove(&id).into_iter().flatten() {
248 if let Some(preds) = self.pred.get_mut(&succ) {
249 preds.retain(|(p, _)| *p != id);
250 }
251 }
252 for (pred_, _) in self.pred.remove(&id).into_iter().flatten() {
253 if let Some(succs) = self.succ.get_mut(&pred_) {
254 succs.retain(|(s, _)| *s != id);
255 }
256 }
257
258 if let Some(parent) = self.parent_of.remove(&id)
260 && let Some(children) = self.children.get_mut(&parent)
261 {
262 children.retain(|c| *c != id);
263 }
264 self.children.remove(&id);
265
266 Ok(task)
267 }
268
269 #[must_use]
281 pub fn task(&self, id: Uuid) -> Option<&Task> {
282 self.tasks.get(&id)
283 }
284
285 #[must_use]
301 pub fn task_mut(&mut self, id: Uuid) -> Option<&mut Task> {
302 self.tasks.get_mut(&id)
303 }
304
305 pub fn tasks(&self) -> impl Iterator<Item = &Task> {
317 self.task_order.iter().filter_map(|id| self.tasks.get(id))
318 }
319
320 pub fn tasks_mut(&mut self) -> impl Iterator<Item = &mut Task> {
332 self.tasks.values_mut()
333 }
334
335 pub fn add_time_relationship(
360 &mut self,
361 predecessor: Uuid,
362 successor: Uuid,
363 kind: TimeRelationship,
364 ) -> anyhow::Result<()> {
365 if !self.tasks.contains_key(&predecessor) || !self.tasks.contains_key(&successor) {
366 bail!("Task not found");
367 }
368
369 if self
370 .succ
371 .get(&predecessor)
372 .map(|e| e.iter().any(|(s, _)| *s == successor))
373 .unwrap_or(false)
374 {
375 bail!("Relationship between tasks already exists");
376 }
377
378 if self.would_cycle(successor, predecessor) {
379 bail!("A cycle was detected between tasks {predecessor} and {successor}");
380 }
381
382 self.add_one_edge(predecessor, successor, kind);
383 Ok(())
384 }
385
386 pub fn rm_time_relationship(
410 &mut self,
411 predecessor: Uuid,
412 successor: Uuid,
413 ) -> anyhow::Result<()> {
414 let exists = self
415 .succ
416 .get(&predecessor)
417 .map(|e| e.iter().any(|(s, _)| *s == successor))
418 .unwrap_or(false);
419 if !exists {
420 bail!("Tried to remove a relationship that doesn't exist");
421 }
422 self.remove_one_edge(predecessor, successor);
423 Ok(())
424 }
425
426 pub fn successors(&self, id: Uuid) -> impl Iterator<Item = &Task> {
441 self.succ
442 .get(&id)
443 .into_iter()
444 .flatten()
445 .filter_map(move |(succ_id, _)| self.tasks.get(succ_id))
446 }
447
448 pub fn successors_ids(&self, id: Uuid) -> impl Iterator<Item = Uuid> {
463 self.succ
464 .get(&id)
465 .into_iter()
466 .flatten()
467 .map(|(succ_id, _)| *succ_id)
468 }
469
470 pub fn predecessors(&self, id: Uuid) -> impl Iterator<Item = &Task> {
485 self.pred
486 .get(&id)
487 .into_iter()
488 .flatten()
489 .filter_map(move |(pred_id, _)| self.tasks.get(pred_id))
490 }
491
492 pub fn predecessors_ids(&self, id: Uuid) -> impl Iterator<Item = Uuid> {
507 self.pred
508 .get(&id)
509 .into_iter()
510 .flatten()
511 .map(|(pred_id, _)| *pred_id)
512 }
513
514 pub fn update_relationships(
543 &mut self,
544 task_id: Uuid,
545 ids: &[Uuid],
546 dir: RelDir,
547 kind: TimeRelationship,
548 ) -> anyhow::Result<()> {
549 if !self.tasks.contains_key(&task_id) {
550 bail!("Task {task_id} doesn't exist");
551 }
552 for &id in ids {
553 if !self.tasks.contains_key(&id) {
554 bail!("Task {id} doesn't exist");
555 }
556 }
557
558 let old: HashSet<Uuid> = match dir {
559 RelDir::Predecessors => self.predecessors_ids(task_id).collect(),
560 RelDir::Successors => self.successors_ids(task_id).collect(),
561 };
562 let new: HashSet<Uuid> = ids.iter().copied().collect();
563
564 let to_add: Vec<Uuid> = ids.iter().filter(|i| !old.contains(i)).copied().collect();
565 let to_remove: Vec<Uuid> = old.iter().filter(|i| !new.contains(i)).copied().collect();
566
567 let mut added = Vec::new();
568 for &i in &to_add {
569 let (pred, succ) = match dir {
570 RelDir::Predecessors => (i, task_id),
571 RelDir::Successors => (task_id, i),
572 };
573 if self.is_ancestor(pred, succ) || self.is_ancestor(succ, pred) {
574 for &(p, s) in &added {
575 self.remove_one_edge(p, s);
576 }
577 bail!(
578 "Cannot add a predecessor/successor relationship between an ancestor and a descendant"
579 );
580 }
581 if self.would_cycle(succ, pred) {
582 for &(p, s) in &added {
583 self.remove_one_edge(p, s);
584 }
585 bail!("A cycle was detected between tasks {i} and {task_id}");
586 }
587 self.add_one_edge(pred, succ, kind);
588 added.push((pred, succ));
589 }
590
591 for &i in &to_remove {
592 let (pred, succ) = match dir {
593 RelDir::Predecessors => (i, task_id),
594 RelDir::Successors => (task_id, i),
595 };
596 self.remove_one_edge(pred, succ);
597 }
598
599 Ok(())
600 }
601
602 pub fn move_task_after(&mut self, id: Uuid, after_id: Uuid) {
619 let id_pos = self.task_order.iter().position(|&t| t == id);
620 let after_pos = self.task_order.iter().position(|&t| t == after_id);
621 if let (Some(ip), Some(ap)) = (id_pos, after_pos) {
622 self.task_order.remove(ip);
623 let insert_at = if ap > ip { ap } else { ap + 1 };
624 self.task_order.insert(insert_at, id);
625 }
626 }
627
628 pub fn add_subtask(&mut self, parent_id: Uuid, child_id: Uuid) -> anyhow::Result<()> {
655 if !self.tasks.contains_key(&parent_id) || !self.tasks.contains_key(&child_id) {
656 bail!("Task not found");
657 }
658 if parent_id == child_id {
659 bail!("A task cannot be a subtask of itself");
660 }
661 if self.parent_of.get(&child_id) == Some(&parent_id) {
663 return Ok(());
664 }
665 let mut current = parent_id;
667 while let Some(&ancestor) = self.parent_of.get(¤t) {
668 if ancestor == child_id {
669 bail!("Cannot make a task a subtask of one of its own descendants");
670 }
671 current = ancestor;
672 }
673 if let Some(old_parent) = self.parent_of.remove(&child_id)
675 && let Some(children) = self.children.get_mut(&old_parent)
676 {
677 children.retain(|c| *c != child_id);
678 if children.is_empty() {
679 self.children.remove(&old_parent);
680 }
681 }
682 self.children.entry(parent_id).or_default().push(child_id);
683 self.parent_of.insert(child_id, parent_id);
684 Ok(())
685 }
686
687 pub fn remove_subtask(&mut self, child_id: Uuid) -> anyhow::Result<()> {
708 let parent = self
709 .parent_of
710 .remove(&child_id)
711 .context("Task is not a subtask")?;
712 if let Some(children) = self.children.get_mut(&parent) {
713 children.retain(|c| *c != child_id);
714 if children.is_empty() {
715 self.children.remove(&parent);
716 }
717 }
718 Ok(())
719 }
720
721 pub fn task_parent(&self, child_id: Uuid) -> Option<Uuid> {
738 self.parent_of.get(&child_id).copied()
739 }
740
741 pub fn subtasks(&self, parent_id: Uuid) -> impl Iterator<Item = Uuid> + '_ {
757 self.children.get(&parent_id).into_iter().flatten().copied()
758 }
759
760 pub fn sync_parent_dates(&mut self, parent_id: Uuid) -> anyhow::Result<()> {
786 let earliest_start = self
787 .subtasks(parent_id)
788 .filter_map(|child_id| self.task(child_id).and_then(|t| t.start()))
789 .min();
790 let latest_finish = self
791 .subtasks(parent_id)
792 .filter_map(|child_id| self.task(child_id).and_then(|t| t.finish()))
793 .max();
794
795 if earliest_start.is_none() && latest_finish.is_none() {
796 return Ok(());
797 }
798
799 let parent = self.task_mut(parent_id).context("Parent task not found")?;
800 if let Some(start) = earliest_start
801 && parent.start().is_none_or(|ps| start < ps)
802 {
803 let _ = parent.edit_start(start);
804 }
805 if let Some(finish) = latest_finish
806 && parent.finish().is_none_or(|pf| finish > pf)
807 {
808 let _ = parent.edit_finish(finish);
809 }
810 Ok(())
811 }
812
813 #[must_use]
826 pub const fn start_date(&self) -> Option<DateTime<Utc>> {
827 self.start_date
828 }
829
830 pub fn add_resource(&mut self, resource: Resource) {
849 self.resources.push(resource);
850 }
851
852 #[must_use]
868 pub fn resource(&self, index: usize) -> Option<&Resource> {
869 self.resources.get(index)
870 }
871
872 #[must_use]
891 pub fn rm_resource(&mut self, index: usize) -> Option<Resource> {
892 if index < self.resources.len() {
893 Some(self.resources.remove(index))
894 } else {
895 None
896 }
897 }
898
899 #[must_use]
925 pub fn resource_mut(&mut self, index: usize) -> Option<&mut Resource> {
926 self.resources.get_mut(index)
927 }
928
929 #[must_use]
943 pub fn resources(&self) -> &[Resource] {
944 &self.resources
945 }
946
947 pub fn res_into_consumable(
970 &mut self,
971 resource_index: usize,
972 ) -> Result<(), ResourceConversionError> {
973 self.convert_resource(resource_index, true)
974 }
975
976 pub fn res_into_nonconsumable(
999 &mut self,
1000 resource_index: usize,
1001 ) -> Result<(), ResourceConversionError> {
1002 self.convert_resource(resource_index, false)
1003 }
1004
1005 fn convert_resource(
1006 &mut self,
1007 resource_index: usize,
1008 to_consumable: bool,
1009 ) -> Result<(), ResourceConversionError> {
1010 let res = self
1011 .resources
1012 .get_mut(resource_index)
1013 .ok_or(ResourceConversionError::ResourceNotFound)?;
1014 let replacement = match res {
1015 Resource::Material(Material::NonConsumable(nc)) if to_consumable => {
1016 Some(Resource::Material(Material::Consumable(nc.clone().into())))
1017 }
1018 Resource::Material(Material::Consumable(c)) if !to_consumable => Some(
1019 Resource::Material(Material::NonConsumable(c.clone().into())),
1020 ),
1021 Resource::Material(Material::Consumable(_))
1022 | Resource::Material(Material::NonConsumable(_)) => None,
1023 _ => return Err(ResourceConversionError::ConversionNotPossible),
1024 };
1025 if let Some(r) = replacement {
1026 *res = r;
1027 }
1028 Ok(())
1029 }
1030
1031 pub fn add_stakeholder(&mut self, stakeholder: Stakeholder) {
1051 self.stakeholders.push(stakeholder);
1052 }
1053
1054 #[must_use]
1070 pub fn stakeholders(&self) -> &[Stakeholder] {
1071 &self.stakeholders
1072 }
1073
1074 #[must_use]
1098 pub fn rm_stakeholder(&mut self, index: usize) -> Option<Stakeholder> {
1099 if index < self.stakeholders.len() {
1100 Some(self.stakeholders.remove(index))
1101 } else {
1102 None
1103 }
1104 }
1105
1106 fn is_ancestor(&self, ancestor_id: Uuid, descendant_id: Uuid) -> bool {
1108 let mut seen = HashSet::new();
1109 let mut current = descendant_id;
1110 while let Some(parent) = self.task_parent(current) {
1111 if !seen.insert(parent) {
1112 break;
1113 }
1114 if parent == ancestor_id {
1115 return true;
1116 }
1117 current = parent;
1118 }
1119 false
1120 }
1121
1122 fn would_cycle(&self, from: Uuid, to: Uuid) -> bool {
1124 let mut seen = HashSet::new();
1125 let mut q = VecDeque::new();
1126 q.push_back(from);
1127 while let Some(v) = q.pop_front() {
1128 if v == to {
1129 return true;
1130 }
1131 if seen.insert(v)
1132 && let Some(succs) = self.succ.get(&v)
1133 {
1134 for (s, _) in succs {
1135 q.push_back(*s);
1136 }
1137 }
1138 }
1139 false
1140 }
1141
1142 fn add_one_edge(&mut self, pred: Uuid, succ: Uuid, kind: TimeRelationship) {
1143 self.succ.entry(pred).or_default().push((succ, kind));
1144 self.pred.entry(succ).or_default().push((pred, kind));
1145 }
1146
1147 fn remove_one_edge(&mut self, pred: Uuid, succ: Uuid) {
1148 if let Some(entries) = self.succ.get_mut(&pred) {
1149 entries.retain(|(s, _)| *s != succ);
1150 if entries.is_empty() {
1151 self.succ.remove(&pred);
1152 }
1153 }
1154 if let Some(entries) = self.pred.get_mut(&succ) {
1155 entries.retain(|(p, _)| *p != pred);
1156 if entries.is_empty() {
1157 self.pred.remove(&succ);
1158 }
1159 }
1160 }
1161}
1162
1163#[derive(Error, Debug, PartialEq, Eq)]
1165pub enum ResourceConversionError {
1166 #[error("The resource with the specified index wasn't found")]
1168 ResourceNotFound,
1169 #[error("Tried to convert a resource that's not a material")]
1171 ConversionNotPossible,
1172}
1173
1174#[cfg(test)]
1175pub mod test_utils {
1177 use proptest::{collection, prelude::*};
1178
1179 use crate::task::{Task, test_utils::task_strategy};
1180
1181 use super::{Project, RelDir, TimeRelationship};
1182
1183 const MAX_TASKS: usize = 100;
1184 const MIN_TASKS: usize = 5;
1185
1186 pub fn tasks_strategy() -> impl Strategy<Value = Vec<Task>> {
1188 collection::vec(task_strategy(), MIN_TASKS..MAX_TASKS)
1189 }
1190
1191 pub fn project_graph_strategy() -> impl Strategy<Value = Project> {
1193 (".*", tasks_strategy()).prop_map(|(n, tasks)| {
1194 let mut project = Project::builder().name(n).build();
1195 let mut ids = Vec::new();
1196 for task in tasks {
1197 ids.push(project.add_task(task));
1198 }
1199
1200 let mut previous = None;
1201 for ¤t in &ids {
1202 if let Some(prev) = previous {
1203 project
1204 .update_relationships(
1205 prev,
1206 &[current],
1207 RelDir::Successors,
1208 TimeRelationship::FinishToStart,
1209 )
1210 .unwrap();
1211 }
1212 previous = Some(current);
1213 }
1214 project
1215 })
1216 }
1217
1218 pub fn project_strategy() -> impl Strategy<Value = Project> {
1220 (".*", tasks_strategy()).prop_map(|(n, tasks)| {
1221 let mut project = Project::builder().name(n).build();
1222 for task in tasks {
1223 project.add_task(task);
1224 }
1225 project
1226 })
1227 }
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232 use proptest::prelude::*;
1233 use rand::{RngExt, rng};
1234
1235 use chrono::Utc;
1236 use uuid::Uuid;
1237
1238 use crate::{
1239 person::Person,
1240 project::{
1241 Project, RelDir, ResourceConversionError, TimeRelationship,
1242 test_utils::{project_graph_strategy, project_strategy},
1243 },
1244 resources::{Consumable, Material, NonConsumable, Resource},
1245 stakeholders::Stakeholder,
1246 task::Task,
1247 };
1248
1249 fn task_ids(project: &Project) -> Vec<Uuid> {
1250 project.tasks().map(|t| t.id()).collect()
1251 }
1252
1253 proptest! {
1254 #[test]
1255 fn update_relationships_predecessor_rejects_circular_graphs(mut project in project_graph_strategy()) {
1256 let ids = task_ids(&project);
1257 if ids.len() < 2 { return Ok(()); }
1258 let last = *ids.last().unwrap();
1259 assert!(project.update_relationships(ids[0], &[last], RelDir::Predecessors, TimeRelationship::FinishToStart).is_err());
1260 }
1261
1262 #[test]
1263 fn update_relationships_rejects_circular_graphs(mut project in project_graph_strategy()) {
1264 let ids = task_ids(&project);
1265 if ids.len() < 2 { return Ok(()); }
1266 let last = *ids.last().unwrap();
1267 assert!(project.update_relationships(last, &[ids[0]], RelDir::Successors, TimeRelationship::FinishToStart).is_err());
1268 }
1269
1270 #[test]
1271 fn update_relationships_rejects_non_existent_ids(mut project in project_strategy()) {
1272 let ids = task_ids(&project);
1273 if ids.is_empty() { return Ok(()); }
1274 let fake = Uuid::new_v4();
1275 assert!(project.update_relationships(ids[0], &[fake], RelDir::Predecessors, TimeRelationship::FinishToStart).is_err());
1276 assert!(project.update_relationships(ids[0], &[fake], RelDir::Successors, TimeRelationship::FinishToStart).is_err());
1277 }
1278
1279 #[test]
1280 fn update_relationships_predecessor_removes_them_if_input_is_empty(mut project in project_strategy()) {
1281 let ids = task_ids(&project);
1282 if ids.len() < 2 { return Ok(()); }
1283 let mut rng = rng();
1284 let idx1 = rng.random_range(0..ids.len());
1285 let mut idx2 = idx1;
1286 while idx2 == idx1 {
1287 idx2 = rng.random_range(0..ids.len());
1288 }
1289
1290 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1291 project.update_relationships(ids[idx1], &[], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1292
1293 assert_eq!(project.predecessors(ids[idx1]).count(), 0);
1294 }
1295
1296 #[test]
1297 fn update_relationships_predecessor_removes_ids_not_present_in_input(mut project in project_strategy()) {
1298 let ids = task_ids(&project);
1299 if ids.len() < 3 { return Ok(()); }
1300 let mut rng = rng();
1301 let idx1 = rng.random_range(0..ids.len());
1302 let mut idx2 = idx1;
1303 let mut idx3 = idx1;
1304 while idx2 == idx1 {
1305 idx2 = rng.random_range(0..ids.len());
1306 }
1307 while idx3 == idx1 || idx3 == idx2 {
1308 idx3 = rng.random_range(0..ids.len());
1309 }
1310
1311 project.update_relationships(ids[idx1], &[ids[idx2], ids[idx3]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1312 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1313
1314 let mut predecessors = project.predecessors(ids[idx1]);
1315 assert_eq!(predecessors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1316 assert!(predecessors.next().is_none());
1317 }
1318
1319 #[test]
1320 fn update_relationships_predecessor_works(mut project in project_strategy()) {
1321 let ids = task_ids(&project);
1322 if ids.len() < 2 { return Ok(()); }
1323 let mut rng = rng();
1324 let idx1 = rng.random_range(0..ids.len());
1325 let mut idx2 = idx1;
1326 while idx2 == idx1 {
1327 idx2 = rng.random_range(0..ids.len());
1328 }
1329
1330 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Predecessors, TimeRelationship::FinishToStart).unwrap();
1331
1332 assert_eq!(project.predecessors(ids[idx1]).count(), 1);
1333 assert_eq!(
1334 project.predecessors(ids[idx1]).next().map(|t| t.name()),
1335 project.task(ids[idx2]).map(|t| t.name())
1336 );
1337 }
1338
1339 #[test]
1340 fn update_relationships_works(mut project in project_strategy()) {
1341 let ids = task_ids(&project);
1342 if ids.len() < 2 { return Ok(()); }
1343 let mut rng = rng();
1344 let idx1 = rng.random_range(0..ids.len());
1345 let mut idx2 = idx1;
1346 while idx2 == idx1 {
1347 idx2 = rng.random_range(0..ids.len());
1348 }
1349
1350 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1351
1352 let mut successors = project.successors(ids[idx1]);
1353 assert_eq!(successors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1354 assert!(successors.next().is_none());
1355 }
1356
1357 #[test]
1358 fn update_relationships_removes_them_if_input_is_empty(mut project in project_strategy()) {
1359 let ids = task_ids(&project);
1360 if ids.len() < 2 { return Ok(()); }
1361 let mut rng = rng();
1362 let idx1 = rng.random_range(0..ids.len());
1363 let mut idx2 = idx1;
1364 while idx2 == idx1 {
1365 idx2 = rng.random_range(0..ids.len());
1366 }
1367
1368 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1369 project.update_relationships(ids[idx1], &[], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1370
1371 assert_eq!(project.successors(ids[idx1]).count(), 0);
1372 }
1373
1374 #[test]
1375 fn update_relationships_removes_ids_not_present_in_input(mut project in project_strategy()) {
1376 let ids = task_ids(&project);
1377 if ids.len() < 3 { return Ok(()); }
1378 let mut rng = rng();
1379 let idx1 = rng.random_range(0..ids.len());
1380 let mut idx2 = idx1;
1381 let mut idx3 = idx1;
1382 while idx2 == idx1 {
1383 idx2 = rng.random_range(0..ids.len());
1384 }
1385 while idx3 == idx1 || idx3 == idx2 {
1386 idx3 = rng.random_range(0..ids.len());
1387 }
1388
1389 project.update_relationships(ids[idx1], &[ids[idx2], ids[idx3]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1390 project.update_relationships(ids[idx1], &[ids[idx2]], RelDir::Successors, TimeRelationship::FinishToStart).unwrap();
1391
1392 let mut successors = project.successors(ids[idx1]);
1393 assert_eq!(successors.next().map(|t| t.name()), project.task(ids[idx2]).map(|t| t.name()));
1394 assert!(successors.next().is_none());
1395 }
1396 }
1397
1398 #[test]
1399 fn update_relationships_rolls_back_partial_additions_on_cycle() {
1400 let mut project = Project::new("test");
1401 let a = project.add_task(Task::new("A"));
1402 let b = project.add_task(Task::new("B"));
1403 let c = project.add_task(Task::new("C"));
1404 let d = project.add_task(Task::new("D"));
1405
1406 project
1407 .add_time_relationship(a, b, TimeRelationship::FinishToStart)
1408 .unwrap();
1409 project
1410 .add_time_relationship(b, c, TimeRelationship::FinishToStart)
1411 .unwrap();
1412 project
1413 .add_time_relationship(c, d, TimeRelationship::FinishToStart)
1414 .unwrap();
1415
1416 let old_preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1417 assert_eq!(old_preds, vec![b]);
1418
1419 let result = project.update_relationships(
1420 c,
1421 &[a, d],
1422 RelDir::Predecessors,
1423 TimeRelationship::FinishToStart,
1424 );
1425 assert!(result.is_err());
1426
1427 let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1428 assert_eq!(
1429 preds,
1430 vec![b],
1431 "predecessors should be unchanged after rollback"
1432 );
1433 assert!(
1434 !project.predecessors_ids(c).any(|i| i == a),
1435 "partially-added edge a→c should have been rolled back"
1436 );
1437 }
1438
1439 #[test]
1440 fn update_relationships_handles_overlap() {
1441 let mut project = Project::new("test");
1442 let a = project.add_task(Task::new("A"));
1443 let b = project.add_task(Task::new("B"));
1444 let c = project.add_task(Task::new("C"));
1445 let d = project.add_task(Task::new("D"));
1446
1447 project
1448 .update_relationships(
1449 c,
1450 &[a, b],
1451 RelDir::Predecessors,
1452 TimeRelationship::FinishToStart,
1453 )
1454 .unwrap();
1455 let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1456 assert!(preds.contains(&a), "should contain a, got {preds:?}");
1457 assert!(preds.contains(&b), "should contain b, got {preds:?}");
1458
1459 project
1460 .update_relationships(
1461 c,
1462 &[b, d],
1463 RelDir::Predecessors,
1464 TimeRelationship::FinishToStart,
1465 )
1466 .unwrap();
1467 let preds: Vec<Uuid> = project.predecessors_ids(c).collect();
1468 assert!(preds.contains(&b));
1469 assert!(preds.contains(&d));
1470 assert!(!preds.contains(&a));
1471 }
1472
1473 #[test]
1474 fn res_into_consumable_returns_the_correct_errors() {
1475 let mut project = Project::new("World domination");
1476
1477 project.add_resource(Resource::Personnel {
1478 person: Person::new("Sebastiano", "Giordano").unwrap(),
1479 hourly_rate: None,
1480 });
1481
1482 assert_eq!(
1483 project.res_into_consumable(0),
1484 Err(ResourceConversionError::ConversionNotPossible)
1485 );
1486
1487 assert_eq!(
1488 project.res_into_consumable(1),
1489 Err(ResourceConversionError::ResourceNotFound)
1490 );
1491
1492 project.add_resource(Resource::Material(Material::Consumable(Consumable::new(
1493 "Stimpack",
1494 ))));
1495
1496 assert!(project.res_into_consumable(1).is_ok());
1497
1498 if let Resource::Material(Material::Consumable(_)) = project.resources()[1] {
1499 } else {
1500 panic!("It changed the resource type");
1501 }
1502
1503 project.add_resource(Resource::Material(Material::NonConsumable(
1504 NonConsumable::new("Crowbar"),
1505 )));
1506 project.res_into_consumable(2).unwrap();
1507 if let Resource::Material(Material::Consumable(_)) = project.resources()[2] {
1508 } else {
1509 panic!("It didn't change the resource type");
1510 }
1511 }
1512
1513 #[test]
1514 fn res_into_nonconsumable_returns_the_correct_errors() {
1515 let mut project = Project::new("World domination");
1516
1517 project.add_resource(Resource::Personnel {
1518 person: Person::new("Sebastiano", "Giordano").unwrap(),
1519 hourly_rate: None,
1520 });
1521
1522 assert_eq!(
1523 project.res_into_nonconsumable(0),
1524 Err(ResourceConversionError::ConversionNotPossible)
1525 );
1526
1527 assert_eq!(
1528 project.res_into_nonconsumable(1),
1529 Err(ResourceConversionError::ResourceNotFound)
1530 );
1531
1532 project.add_resource(Resource::Material(Material::NonConsumable(
1533 NonConsumable::new("Crowbar"),
1534 )));
1535
1536 assert!(project.res_into_nonconsumable(1).is_ok());
1537
1538 if let Resource::Material(Material::NonConsumable(_)) = project.resources()[1] {
1539 } else {
1540 panic!("It changed the resource type");
1541 }
1542
1543 project.add_resource(Resource::Material(Material::Consumable(Consumable::new(
1544 "Stimpack",
1545 ))));
1546 project.res_into_nonconsumable(2).unwrap();
1547 if let Resource::Material(Material::NonConsumable(_)) = project.resources()[2] {
1548 } else {
1549 panic!("It didn't change the resource type");
1550 }
1551 }
1552
1553 fn name_strategy() -> impl Strategy<Value = String> {
1554 r"[a-zA-Z0-9]{1,30}"
1555 }
1556
1557 proptest! {
1558 #[test]
1559 fn task_add_rm_lifecycle(mut project in project_strategy()) {
1560 let initial_count = project.tasks().count();
1561 let id = project.add_task(Task::new("new task"));
1562 assert_eq!(project.tasks().count(), initial_count + 1);
1563 project.rm_task(id).unwrap();
1564 assert_eq!(project.tasks().count(), initial_count);
1565 for task in project.tasks() {
1566 assert_ne!(task.name(), "new task");
1567 }
1568 }
1569
1570 #[test]
1571 fn rm_task_cleans_subtask_relationships(name in name_strategy()) {
1572 let mut project = Project::new(name);
1573 let parent = project.add_task(Task::new("parent"));
1574 let child = project.add_task(Task::new("child"));
1575 project.add_subtask(parent, child).unwrap();
1576 assert_eq!(project.subtasks(parent).collect::<Vec<_>>(), vec![child]);
1577 project.rm_task(parent).unwrap();
1578 assert!(project.subtasks(parent).next().is_none());
1579 }
1580
1581 #[test]
1582 fn resource_add_rm_lifecycle(name in name_strategy()) {
1583 let mut project = Project::new(name);
1584 assert_eq!(project.resources().len(), 0);
1585 project.add_resource(Resource::Material(Material::new("widget")));
1586 assert_eq!(project.resources().len(), 1);
1587 project.add_resource(Resource::Material(Material::new("gadget")));
1588 assert_eq!(project.resources().len(), 2);
1589 let removed = project.rm_resource(0).unwrap();
1590 assert!(matches!(removed, Resource::Material(ref m) if m.name() == "widget"));
1591 assert_eq!(project.resources().len(), 1);
1592 assert!(matches!(project.resources()[0], Resource::Material(ref m) if m.name() == "gadget"));
1593 }
1594
1595 #[test]
1596 fn stakeholder_add_increases_count(name in name_strategy(), first in "[a-zA-Z]{1,50}", last in "[a-zA-Z]{1,50}") {
1597 let mut project = Project::new(name);
1598 let p = Person::new(&first, &last).unwrap();
1599 project.add_stakeholder(Stakeholder::Individual { person: p, description: None });
1600 assert_eq!(project.stakeholders().len(), 1);
1601 }
1602
1603 #[test]
1604 fn rm_stakeholder_removes_and_returns(name in name_strategy(), first in "[a-zA-Z]{1,50}", last in "[a-zA-Z]{1,50}") {
1605 let mut project = Project::new(name);
1606 let p = Person::new(&first, &last).unwrap();
1607 project.add_stakeholder(Stakeholder::Individual { person: p.clone(), description: None });
1608 assert_eq!(project.stakeholders().len(), 1);
1609 let removed = project.rm_stakeholder(0);
1610 assert!(removed.is_some());
1611 assert_eq!(project.stakeholders().len(), 0);
1612 assert!(project.rm_stakeholder(0).is_none());
1613 }
1614
1615 #[test]
1616 fn add_time_relationship_works(mut project in project_strategy()) {
1617 let ids = task_ids(&project);
1618 if ids.len() < 2 { return Ok(()); }
1619 let mut rng = rand::rng();
1620 let p = rng.random_range(0..ids.len());
1621 let mut s = p;
1622 while s == p {
1623 s = rng.random_range(0..ids.len());
1624 }
1625
1626 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
1627 let succs: Vec<_> = project.successors_ids(ids[p]).collect();
1628 assert!(succs.contains(&ids[s]), "successors({}) should contain {}", ids[p], ids[s]);
1629 let preds: Vec<_> = project.predecessors_ids(ids[s]).collect();
1630 assert!(preds.contains(&ids[p]), "predecessors({}) should contain {}", ids[s], ids[p]);
1631 }
1632
1633 #[test]
1634 fn add_time_relationship_rejects_duplicate(mut project in project_strategy()) {
1635 let ids = task_ids(&project);
1636 if ids.len() < 2 { return Ok(()); }
1637 let mut rng = rand::rng();
1638 let p = rng.random_range(0..ids.len());
1639 let mut s = p;
1640 while s == p {
1641 s = rng.random_range(0..ids.len());
1642 }
1643
1644 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
1645 assert!(
1646 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).is_err(),
1647 "duplicate edge should be rejected"
1648 );
1649 }
1650
1651 #[test]
1652 fn rm_time_relationship_works(mut project in project_strategy()) {
1653 let ids = task_ids(&project);
1654 if ids.len() < 2 { return Ok(()); }
1655 let mut rng = rand::rng();
1656 let p = rng.random_range(0..ids.len());
1657 let mut s = p;
1658 while s == p {
1659 s = rng.random_range(0..ids.len());
1660 }
1661
1662 project.add_time_relationship(ids[p], ids[s], TimeRelationship::FinishToStart).unwrap();
1663 project.rm_time_relationship(ids[p], ids[s]).unwrap();
1664 let succs: Vec<_> = project.successors_ids(ids[p]).collect();
1665 assert!(!succs.contains(&ids[s]), "successors({}) should not contain {}", ids[p], ids[s]);
1666 }
1667
1668 #[test]
1669 fn add_subtask_works(mut project in project_strategy()) {
1670 let ids = task_ids(&project);
1671 if ids.len() < 2 { return Ok(()); }
1672 let mut rng = rand::rng();
1673 let p = rng.random_range(0..ids.len());
1674 let mut c = p;
1675 while c == p {
1676 c = rng.random_range(0..ids.len());
1677 }
1678
1679 project.add_subtask(ids[p], ids[c]).unwrap();
1680 assert!(project.subtasks(ids[p]).any(|s| s == ids[c]), "subtasks({}) should contain {}", ids[p], ids[c]);
1681 }
1682
1683 #[test]
1684 fn res_into_consumable_preserves_fields(res_name in name_strategy(), qty in 1u16..1000u16, cost in 1u16..1000u16) {
1685 let mut project = Project::new("project");
1686 let mut m = Material::NonConsumable(NonConsumable::new(res_name.clone()));
1687 m.update_quantity(qty);
1688 m.update_cost_per_unit(cost);
1689 project.add_resource(Resource::Material(m));
1690 project.res_into_consumable(0).unwrap();
1691 if let Resource::Material(ref m) = project.resources()[0] {
1692 assert_eq!(m.name(), res_name);
1693 assert_eq!(m.quantity(), Some(qty));
1694 assert_eq!(m.cost_per_unit(), Some(cost));
1695 } else {
1696 panic!("Expected Material");
1697 }
1698 }
1699
1700 #[test]
1701 fn res_into_nonconsumable_preserves_fields(res_name in name_strategy(), qty in 1u16..1000u16, cost in 1u16..1000u16) {
1702 let mut project = Project::new("project");
1703 let mut m = Material::Consumable(Consumable::new(res_name.clone()));
1704 m.update_quantity(qty);
1705 m.update_cost_per_unit(cost);
1706 project.add_resource(Resource::Material(m));
1707 project.res_into_nonconsumable(0).unwrap();
1708 if let Resource::Material(ref m) = project.resources()[0] {
1709 assert_eq!(m.name(), res_name);
1710 assert_eq!(m.quantity(), Some(qty));
1711 assert_eq!(m.cost_per_unit(), Some(cost));
1712 } else {
1713 panic!("Expected Material");
1714 }
1715 }
1716
1717 #[test]
1718 fn add_subtask_rejects_invalid_ids(name in name_strategy()) {
1719 let mut project = Project::new(name);
1720 let task = project.add_task(Task::new("only task"));
1721 let fake = Uuid::new_v4();
1722 assert!(project.subtasks(fake).next().is_none());
1723 assert!(project.add_subtask(fake, task).is_err());
1724 assert!(project.add_subtask(task, fake).is_err());
1725 }
1726
1727 #[test]
1728 fn rm_time_relationship_rejects_invalid_ids(mut project in project_strategy()) {
1729 let ids = task_ids(&project);
1730 if ids.is_empty() { return Ok(()); }
1731 let fake = Uuid::new_v4();
1732 assert!(project.rm_time_relationship(ids[0], fake).is_err());
1733 assert!(project.rm_time_relationship(fake, ids[0]).is_err());
1734 }
1735
1736 #[test]
1737 fn rm_task_rejects_invalid_id(name in name_strategy()) {
1738 let mut project = Project::new(name);
1739 let fake = Uuid::new_v4();
1740 assert!(project.rm_task(fake).is_err());
1741 }
1742
1743 #[test]
1744 fn res_into_consumable_on_personnel_returns_error(name in name_strategy()) {
1745 let mut project = Project::new(name);
1746 let p = Person::new("test", "person").unwrap();
1747 project.add_resource(Resource::Personnel { person: p, hourly_rate: None });
1748 assert_eq!(
1749 project.res_into_consumable(0),
1750 Err(ResourceConversionError::ConversionNotPossible)
1751 );
1752 }
1753
1754 #[test]
1755 fn res_into_nonconsumable_on_personnel_returns_error(name in name_strategy()) {
1756 let mut project = Project::new(name);
1757 let p = Person::new("test", "person").unwrap();
1758 project.add_resource(Resource::Personnel { person: p, hourly_rate: None });
1759 assert_eq!(
1760 project.res_into_nonconsumable(0),
1761 Err(ResourceConversionError::ConversionNotPossible)
1762 );
1763 }
1764 }
1765
1766 #[test]
1767 fn add_time_relationship_rejects_invalid_ids() {
1768 let mut project = Project::new("test");
1769 let task = project.add_task(Task::new("task"));
1770 let fake = Uuid::new_v4();
1771 assert!(
1772 project
1773 .add_time_relationship(fake, task, TimeRelationship::FinishToStart)
1774 .is_err()
1775 );
1776 assert!(
1777 project
1778 .add_time_relationship(task, fake, TimeRelationship::FinishToStart)
1779 .is_err()
1780 );
1781 }
1782
1783 #[test]
1784 fn add_sibling_before_falls_back_to_end_when_sibling_not_found() {
1785 let mut project = Project::new("World domination");
1786 let a = project.add_task(Task::new("Build an army"));
1787 let fake = Uuid::new_v4();
1788 let b = project.add_sibling_before(Task::new("Train troops"), fake);
1789
1790 let ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1791 assert_eq!(ids, vec![a, b]);
1792 }
1793
1794 #[test]
1795 fn move_task_after_is_noop_for_nonexistent_ids() {
1796 let mut project = Project::new("World domination");
1797 let a = project.add_task(Task::new("Build an army"));
1798 let b = project.add_task(Task::new("Train troops"));
1799 let fake = Uuid::new_v4();
1800
1801 project.move_task_after(fake, a);
1802 assert_eq!(
1803 project.tasks().map(|t| t.id()).collect::<Vec<_>>(),
1804 vec![a, b]
1805 );
1806
1807 project.move_task_after(a, fake);
1808 assert_eq!(
1809 project.tasks().map(|t| t.id()).collect::<Vec<_>>(),
1810 vec![a, b]
1811 );
1812 }
1813
1814 #[test]
1815 fn remove_subtask_rejects_non_subtask() {
1816 let mut project = Project::new("World domination");
1817 let task = project.add_task(Task::new("Do something"));
1818 assert!(project.remove_subtask(task).is_err());
1819 }
1820
1821 proptest! {
1822 #[test]
1823 fn add_sibling_before_inserts_correctly(mut project in project_strategy()) {
1824 let ids = task_ids(&project);
1825 if ids.len() < 2 { return Ok(()); }
1826 let mut rng = rand::rng();
1827 let idx = rng.random_range(0..ids.len());
1828
1829 let sibling_id = ids[idx];
1830 let new_id = project.add_sibling_before(Task::new("Minion"), sibling_id);
1831
1832 let ordered_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1833 let new_pos = ordered_ids.iter().position(|&id| id == new_id).unwrap();
1834 let sibling_pos = ordered_ids.iter().position(|&id| id == sibling_id).unwrap();
1835 assert_eq!(new_pos, sibling_pos - 1);
1836 }
1837
1838 #[test]
1839 fn add_sibling_after_inserts_correctly(mut project in project_strategy()) {
1840 let ids = task_ids(&project);
1841 if ids.len() < 2 { return Ok(()); }
1842 let mut rng = rand::rng();
1843 let idx = rng.random_range(0..ids.len());
1844
1845 let sibling_id = ids[idx];
1846 let new_id = project.add_sibling_after(Task::new("Minion"), sibling_id);
1847
1848 let ordered_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1849 let new_pos = ordered_ids.iter().position(|&id| id == new_id).unwrap();
1850 let sibling_pos = ordered_ids.iter().position(|&id| id == sibling_id).unwrap();
1851 assert_eq!(new_pos, sibling_pos + 1);
1852 }
1853
1854 #[test]
1855 fn add_sibling_inherits_parent(mut project in project_strategy()) {
1856 let ids = task_ids(&project);
1857 if ids.len() < 3 { return Ok(()); }
1858 let mut rng = rand::rng();
1859 let parent_idx = rng.random_range(0..ids.len());
1860 let child_idx = rng.random_range(0..ids.len());
1861 if parent_idx == child_idx { return Ok(()); }
1862
1863 project.add_subtask(ids[parent_idx], ids[child_idx]).unwrap();
1864 let new_id = project.add_sibling_before(Task::new("Minion"), ids[child_idx]);
1865
1866 assert_eq!(project.task_parent(new_id), Some(ids[parent_idx]));
1867 let children: Vec<Uuid> = project.subtasks(ids[parent_idx]).collect();
1868 assert!(children.contains(&new_id));
1869 }
1870
1871 #[test]
1872 fn move_task_after_reorders_correctly(mut project in project_strategy()) {
1873 let ids = task_ids(&project);
1874 if ids.len() < 3 { return Ok(()); }
1875 let mut rng = rand::rng();
1876 let idx = rng.random_range(0..ids.len());
1877 let mut after_idx = rng.random_range(0..ids.len());
1878 while after_idx == idx {
1879 after_idx = rng.random_range(0..ids.len());
1880 }
1881
1882 project.move_task_after(ids[idx], ids[after_idx]);
1883 let new_ids: Vec<Uuid> = project.tasks().map(|t| t.id()).collect();
1884
1885 let task_pos = new_ids.iter().position(|&id| id == ids[idx]).unwrap();
1886 let after_pos = new_ids.iter().position(|&id| id == ids[after_idx]).unwrap();
1887 assert_eq!(task_pos, after_pos + 1);
1888 }
1889
1890 #[test]
1891 fn remove_subtask_promotes_to_top_level(mut project in project_strategy()) {
1892 let ids = task_ids(&project);
1893 if ids.len() < 2 { return Ok(()); }
1894 let mut rng = rand::rng();
1895 let parent_idx = rng.random_range(0..ids.len());
1896 let child_idx = rng.random_range(0..ids.len());
1897 if parent_idx == child_idx { return Ok(()); }
1898
1899 project.add_subtask(ids[parent_idx], ids[child_idx]).unwrap();
1900 assert!(project.task_parent(ids[child_idx]).is_some());
1901 project.remove_subtask(ids[child_idx]).unwrap();
1902
1903 assert!(project.task_parent(ids[child_idx]).is_none());
1904 assert!(project.subtasks(ids[parent_idx]).next().is_none());
1905 }
1906
1907 #[test]
1908 fn sync_parent_dates_expands_to_children(
1909 mut project in project_strategy(),
1910 start_offset in 0..1_000_000i64,
1911 finish_offset in 0..1_000_000i64,
1912 ) {
1913 let ids = task_ids(&project);
1914 if ids.len() < 3 { return Ok(()); }
1915 let mut rng = rand::rng();
1916 let parent_idx = rng.random_range(0..ids.len());
1917 let child1_idx = rng.random_range(0..ids.len());
1918 let child2_idx = rng.random_range(0..ids.len());
1919 if child1_idx == parent_idx || child2_idx == parent_idx || child1_idx == child2_idx {
1920 return Ok(());
1921 }
1922
1923 project.add_subtask(ids[parent_idx], ids[child1_idx]).unwrap();
1924 project.add_subtask(ids[parent_idx], ids[child2_idx]).unwrap();
1925
1926 let now = Utc::now();
1927 let child1_start = now - chrono::Duration::milliseconds(start_offset);
1928 let child2_finish = now + chrono::Duration::milliseconds(finish_offset);
1929 project.task_mut(ids[child1_idx]).unwrap().edit_start(child1_start).unwrap();
1930 project.task_mut(ids[child2_idx]).unwrap().edit_finish(child2_finish).unwrap();
1931
1932 project.sync_parent_dates(ids[parent_idx]).unwrap();
1933
1934 assert_eq!(project.task(ids[parent_idx]).unwrap().start(), Some(child1_start));
1935 assert_eq!(project.task(ids[parent_idx]).unwrap().finish(), Some(child2_finish));
1936 }
1937 }
1938
1939 #[test]
1940 fn sync_parent_dates_noop_when_children_have_no_dates() {
1941 let mut project = Project::new("World domination");
1942 let army = project.add_task(Task::new("Build an army"));
1943 let supplies = project.add_task(Task::new("Gather supplies"));
1944 project.add_subtask(army, supplies).unwrap();
1945
1946 let now = Utc::now();
1947 project.task_mut(army).unwrap().edit_start(now).unwrap();
1948
1949 project.sync_parent_dates(army).unwrap();
1950 assert_eq!(project.task(army).unwrap().start(), Some(now));
1951 assert!(project.task(army).unwrap().finish().is_none());
1952 }
1953}