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