Skip to main content

planter_core/
task.rs

1use std::num::NonZeroU32;
2
3use crate::{
4    duration::NonNegativeDuration,
5    identifiable::{self, Identifiable},
6    money::MultiCurrencyAmount,
7    resources::Resource,
8};
9use anyhow::Context;
10use chrono::{DateTime, Utc};
11use uuid::Uuid;
12
13#[derive(Debug, Clone)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15/// A task is a unit of work that can be completed by a person or a group of people.
16/// It can be assigned resources and can have a start, finish, and duration.
17pub struct Task {
18    /// The stable identifier of the task.
19    id: Uuid,
20    /// The name of the task.
21    name: String,
22    /// The description of the task.
23    description: Option<String>,
24    /// Whether the task is completed.
25    completed: bool,
26    /// The start time of the task.
27    start: Option<DateTime<Utc>>,
28    /// The finish time of the task.
29    finish: Option<DateTime<Utc>>,
30    /// The duration of the task.
31    duration: Option<NonNegativeDuration>,
32    /// How many units of each engaged resource this task uses, in the order they were assigned.
33    /// A task engages any given resource at most once.
34    assignments: Vec<Assignment>,
35}
36
37impl Identifiable for Task {
38    fn id(&self) -> Uuid {
39        self.id
40    }
41}
42
43/// How many units of one resource a [`Task`] engages.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46struct Assignment {
47    resource_id: Uuid,
48    quantity: u32,
49}
50
51impl Identifiable for Assignment {
52    fn id(&self) -> Uuid {
53        self.resource_id
54    }
55}
56
57impl Task {
58    /// Creates a new task with the given name.
59    ///
60    /// # Arguments
61    ///
62    /// * `name` - The name of the task.
63    ///
64    /// # Returns
65    ///
66    /// A new task with the given name.
67    ///
68    /// # Example
69    ///
70    /// ```
71    /// use planter_core::task::Task;
72    ///
73    /// let task = Task::new("Become world leader");
74    /// assert_eq!(task.name(), "Become world leader");
75    /// ```
76    #[must_use]
77    pub fn new(name: impl Into<String>) -> Self {
78        Task {
79            id: Uuid::new_v4(),
80            name: name.into(),
81            description: None,
82            completed: false,
83            start: None,
84            finish: None,
85            duration: None,
86            assignments: Vec::new(),
87        }
88    }
89
90    /// Returns the stable identifier of the task.
91    ///
92    /// # Example
93    ///
94    /// ```
95    /// use planter_core::task::Task;
96    ///
97    /// let task = Task::new("Become world leader");
98    /// let id = task.id();
99    /// ```
100    #[must_use]
101    pub const fn id(&self) -> Uuid {
102        self.id
103    }
104
105    /// Edits the start time of the task.
106    /// If a finish time is already set, the duration is recalculated to `finish - start`.
107    /// If the new start time is after the finish time, the finish time is pushed
108    /// ahead to match, resulting in a zero duration.
109    ///
110    /// # Arguments
111    ///
112    /// * `start` - The new start time of the task.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if the task has a finish date and the start date passed
117    /// as parameter is too far from that.
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// use chrono::Utc;
123    /// use planter_core::task::Task;
124    ///
125    /// let mut task = Task::new("Become world leader");
126    /// let start_time = Utc::now();
127    /// task.edit_start(start_time).unwrap();
128    /// assert_eq!(task.start().unwrap(), start_time);
129    /// ```
130    pub fn edit_start(&mut self, start: DateTime<Utc>) -> anyhow::Result<()> {
131        self.start = Some(start);
132
133        if let Some(finish) = self.finish {
134            let finish = if finish < start { start } else { finish };
135            self.finish = Some(finish);
136            self.duration = Some(
137                (finish - start)
138                    .try_into()
139                    .context("Start and finish times were too far apart")?,
140            );
141        } else if let Some(duration) = self.duration {
142            self.finish = Some(start + *duration);
143        }
144        Ok(())
145    }
146
147    /// Returns the start time of the task. It's None by default.
148    ///
149    /// # Example
150    ///
151    /// ```
152    /// use chrono::Utc;
153    /// use planter_core::task::Task;
154    ///
155    /// let mut task = Task::new("Become world leader");
156    /// assert!(task.start().is_none());
157    ///
158    /// let start_time = Utc::now();
159    /// task.edit_start(start_time).unwrap();
160    /// assert_eq!(task.start().unwrap(), start_time);
161    /// ```
162    #[must_use]
163    pub const fn start(&self) -> Option<DateTime<Utc>> {
164        self.start
165    }
166
167    /// Edits the finish time of the task.
168    /// If a start time is already set, the duration is recalculated to `finish - start`.
169    /// If the new finish time is before the start time, the start time is pushed
170    /// back to match, resulting in a zero duration.
171    ///
172    /// # Arguments
173    ///
174    /// * `finish` - The new finish time of the task.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the task has a start date and the finish date passed
179    /// as parameter is too far from that.
180    ///
181    ///
182    /// # Example
183    ///
184    /// ```
185    /// use chrono::Utc;
186    /// use planter_core::task::Task;
187    ///
188    /// let mut task = Task::new("Become world leader");
189    /// assert!(task.start().is_none());
190    ///
191    /// let mut finish_time = Utc::now();
192    /// task.edit_finish(finish_time).unwrap();
193    /// assert_eq!(task.finish().unwrap(), finish_time);
194    /// ```
195    pub fn edit_finish(&mut self, finish: DateTime<Utc>) -> anyhow::Result<()> {
196        self.finish = Some(finish);
197
198        if let Some(start) = self.start {
199            let start = if finish < start {
200                self.start = Some(finish);
201                finish
202            } else {
203                start
204            };
205            let duration = finish - start;
206            self.duration = Some(
207                duration
208                    .try_into()
209                    .context("Start time and finish time were too far apart")?,
210            );
211        } else if let Some(duration) = self.duration {
212            self.start = Some(finish - *duration);
213        }
214        Ok(())
215    }
216
217    /// Returns the finish time of the task. It's None by default.
218    ///
219    /// # Example
220    ///
221    /// ```
222    /// use chrono::Utc;
223    /// use planter_core::task::Task;
224    ///
225    /// let mut task = Task::new("Become world leader");
226    /// assert!(task.finish().is_none());
227    /// let finish_time = Utc::now();
228    /// task.edit_finish(finish_time).unwrap();
229    /// assert_eq!(task.finish().unwrap(), finish_time);
230    /// ```
231    #[must_use]
232    pub const fn finish(&self) -> Option<DateTime<Utc>> {
233        self.finish
234    }
235
236    /// Edits the duration of the task. If the task has a start time, finish time will be updated accordingly.
237    ///
238    /// # Arguments
239    ///
240    /// * `duration` - The new duration of the task.
241    ///
242    /// # Example
243    ///
244    /// ```
245    /// use chrono::{Utc, Duration};
246    /// use planter_core::{task::Task, duration::NonNegativeDuration};
247    ///
248    /// let mut task = Task::new("Become world leader");
249    /// task.edit_duration(Duration::minutes(30).try_into().unwrap());
250    /// assert!(task.duration().is_some());
251    /// assert_eq!(task.duration().unwrap(), Duration::minutes(30).try_into().unwrap());
252    /// ```
253    pub fn edit_duration(&mut self, duration: NonNegativeDuration) {
254        self.duration = Some(duration);
255
256        if let Some(start) = self.start() {
257            let finish = start + *duration;
258            self.finish = Some(finish);
259        } else if let Some(finish) = self.finish() {
260            self.start = Some(finish - *duration);
261        }
262    }
263
264    /// Records that the task engages `quantity` units of `resource_id`, replacing any existing
265    /// entry for that resource (a task engages any given resource at most once). To remove an
266    /// assignment, use [`Self::unassign`] instead.
267    ///
268    /// Internal: a `Task` has no view of the project's resource pool, so this can't check that
269    /// `resource_id` refers to a real resource. Callers go through
270    /// [`crate::project::Project::assign_resource`], which does.
271    pub(crate) fn assign(&mut self, resource_id: Uuid, quantity: NonZeroU32) {
272        identifiable::upsert(
273            &mut self.assignments,
274            Assignment {
275                resource_id,
276                quantity: quantity.get(),
277            },
278        );
279    }
280
281    /// Iterates the task's resource assignments as `(resource_id, quantity)` pairs, in the
282    /// order they were assigned: how many units of each engaged resource the task uses.
283    /// Populate them with [`crate::project::Project::assign_resource`].
284    ///
285    /// # Example
286    ///
287    /// ```
288    /// use planter_core::task::Task;
289    ///
290    /// assert_eq!(Task::new("Become world leader").assignments().count(), 0);
291    /// ```
292    pub fn assignments(&self) -> impl Iterator<Item = (Uuid, u32)> + '_ {
293        self.assignments.iter().map(|a| (a.resource_id, a.quantity))
294    }
295
296    /// Returns how many units of `resource_id` this task engages, or `None` if it doesn't engage
297    /// it at all.
298    ///
299    /// # Example
300    ///
301    /// ```
302    /// use planter_core::task::Task;
303    /// use uuid::Uuid;
304    ///
305    /// assert_eq!(Task::new("Become world leader").assignment(Uuid::new_v4()), None);
306    /// ```
307    #[must_use]
308    pub fn assignment(&self, resource_id: Uuid) -> Option<u32> {
309        identifiable::find(&self.assignments, resource_id).map(|a| a.quantity)
310    }
311
312    /// Computes the task's cost: the sum, grouped by currency, of each assignment's
313    /// [`Resource::usage_cost`](crate::resources::Resource::usage_cost) for the task's duration.
314    /// `resources` is the project's resource pool, used to resolve each assignment.
315    ///
316    /// Each assignment contributes `hourly_rate * hours * quantity`, where `hours` is the task's
317    /// duration in whole hours (`0` if unset). A resource with no rate, or a `resource_id` not
318    /// in `resources`, contributes nothing. One-time purchase costs are not counted here; they
319    /// belong to the resource (see
320    /// [`Resource::purchase_cost`](crate::resources::Resource::purchase_cost)).
321    ///
322    /// Each amount is priced in its resource's own currency; different currencies stay as
323    /// separate entries in the returned [`MultiCurrencyAmount`], never blended. Arithmetic
324    /// saturates instead of overflowing.
325    ///
326    /// Internal: needs the project's resource pool. The public entry point is
327    /// [`crate::project::Project::task_cost`].
328    #[must_use]
329    pub(crate) fn cost(&self, resources: &[Resource]) -> MultiCurrencyAmount {
330        let hours = self.duration_hours();
331
332        self.assignments
333            .iter()
334            .filter_map(|a| {
335                let resource = identifiable::find(resources, a.resource_id)?;
336                resource.usage_cost(hours, a.quantity)
337            })
338            .sum()
339    }
340
341    /// Removes the task's assignment for `resource_id`, returning the quantity it engaged, or
342    /// `None` if the task didn't engage that resource. Also used when the resource is removed
343    /// from the project, so tasks never keep an assignment pointing at nothing.
344    ///
345    /// Internal: the public entry point is [`crate::project::Project::unassign_resource`].
346    pub(crate) fn unassign(&mut self, resource_id: Uuid) -> Option<u32> {
347        identifiable::remove_by_id(&mut self.assignments, resource_id).map(|a| a.quantity)
348    }
349
350    /// Edits the name of the task.
351    ///
352    /// # Arguments
353    ///
354    /// * `name` - The new name of the task.
355    ///
356    /// # Example
357    ///
358    /// ```
359    /// use planter_core::task::Task;
360    ///
361    /// let mut task = Task::new("Become world leader");
362    /// task.edit_name("Become world boss");
363    /// assert_eq!(task.name(), "Become world boss");
364    /// ```
365    pub fn edit_name(&mut self, name: impl Into<String>) {
366        self.name = name.into();
367    }
368
369    /// Returns the name of the task.
370    ///
371    /// # Example
372    ///
373    /// ```
374    /// use planter_core::task::Task;
375    ///
376    /// let mut task = Task::new("Become world leader");
377    /// assert_eq!(task.name(), "Become world leader");
378    /// ```
379    #[must_use]
380    pub fn name(&self) -> &str {
381        &self.name
382    }
383
384    /// Edits the description of the task.
385    ///
386    /// # Arguments
387    ///
388    /// * `description` - The new description of the task.
389    ///
390    /// # Example
391    ///
392    /// ```
393    /// use planter_core::task::Task;
394    ///
395    /// let mut task = Task::new("Become world leader");
396    /// task.edit_description("Description");
397    /// assert_eq!(task.description(), Some("Description"));
398    /// ```
399    pub fn edit_description(&mut self, description: impl Into<String>) {
400        self.description = Some(description.into());
401    }
402
403    /// Clears the description of the task, setting it to `None`.
404    ///
405    /// # Example
406    ///
407    /// ```
408    /// use planter_core::task::Task;
409    ///
410    /// let mut task = Task::new("Become world leader");
411    /// task.edit_description("Description");
412    /// assert_eq!(task.description(), Some("Description"));
413    ///
414    /// task.clear_description();
415    /// assert!(task.description().is_none());
416    /// ```
417    pub fn clear_description(&mut self) {
418        self.description = None;
419    }
420
421    /// Returns the description of the task.
422    ///
423    /// # Example
424    ///
425    /// ```
426    /// use planter_core::task::Task;
427    ///
428    /// let mut task = Task::new("Become world leader");
429    /// task.edit_description("Description");
430    /// assert_eq!(task.description(), Some("Description"));
431    /// ```
432    #[must_use]
433    pub fn description(&self) -> Option<&str> {
434        self.description.as_deref()
435    }
436
437    /// Whether the task is completed. It's false by default.
438    ///
439    /// # Example
440    ///
441    /// ```
442    /// use planter_core::task::Task;
443    ///
444    /// let mut task = Task::new("Become world leader");
445    /// assert!(!task.completed());
446    /// task.toggle_completed();
447    /// assert!(task.completed());
448    /// ```
449    #[must_use]
450    pub const fn completed(&self) -> bool {
451        self.completed
452    }
453
454    /// Marks the task as completed.
455    ///
456    /// # Example
457    ///
458    /// ```
459    /// use planter_core::task::Task;
460    ///
461    /// let mut task = Task::new("Become world leader");
462    /// assert!(!task.completed());
463    /// task.toggle_completed();
464    /// assert!(task.completed());
465    /// task.toggle_completed();
466    /// assert!(!task.completed());
467    /// ```
468    pub const fn toggle_completed(&mut self) {
469        self.completed = !self.completed;
470    }
471
472    /// Returns the duration of the task. It's None by default.
473    ///
474    /// # Example
475    ///
476    /// ```
477    /// use chrono::{Utc, Duration};
478    /// use planter_core::task::Task;
479    ///
480    /// let mut task = Task::new("Become world leader");
481    /// assert!(task.duration().is_none());
482    ///
483    /// task.edit_duration(Duration::hours(1).try_into().unwrap());
484    /// assert!(task.duration().unwrap() == Duration::hours(1).try_into().unwrap());
485    /// ```
486    #[must_use]
487    pub const fn duration(&self) -> Option<NonNegativeDuration> {
488        self.duration
489    }
490
491    /// Returns the task's duration in whole hours, `0` if unset. This is the figure cost
492    /// calculations multiply resource rates by; a partial hour is truncated, saturating at
493    /// [`u64::MAX`].
494    ///
495    /// # Example
496    ///
497    /// ```
498    /// use chrono::Duration;
499    /// use planter_core::task::Task;
500    ///
501    /// let mut task = Task::new("Become world leader");
502    /// assert_eq!(task.duration_hours(), 0);
503    /// task.edit_duration(Duration::minutes(150).try_into().unwrap());
504    /// assert_eq!(task.duration_hours(), 2);
505    /// ```
506    #[must_use]
507    pub fn duration_hours(&self) -> u64 {
508        self.duration
509            .map_or(0, |d| u64::try_from(d.num_hours()).unwrap_or(u64::MAX))
510    }
511}
512
513#[cfg(test)]
514/// Utilities to test Tasks.
515pub mod test_utils {
516    use proptest::prelude::*;
517
518    use super::Task;
519
520    /// Generates an empty task with a random name.
521    pub fn task_strategy() -> impl Strategy<Value = Task> {
522        ".*".prop_map(Task::new)
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use chrono::Duration;
529    use proptest::prelude::*;
530
531    use crate::money::{Currency, Money};
532    use crate::resources::Resource;
533    use crate::task::test_utils::task_strategy;
534
535    use super::*;
536
537    const MAX_TEST_MS: i64 = 1_000_000;
538
539    /// Builds the resource pool `Task::cost` expects.
540    fn pool(resources: impl IntoIterator<Item = Resource>) -> Vec<Resource> {
541        resources.into_iter().collect()
542    }
543
544    /// Shorthand for a non-zero assignment quantity in tests.
545    fn nz(n: u32) -> NonZeroU32 {
546        NonZeroU32::new(n).unwrap()
547    }
548
549    proptest! {
550        #[test]
551        fn duration_is_properly_set_when_adding_start_and_finish_time(milliseconds in 0..MAX_TEST_MS) {
552            let start = Utc::now();
553            let finish = start + Duration::milliseconds(milliseconds);
554            let mut task = Task::new("World domination");
555
556            task.edit_start(start).unwrap();
557            task.edit_finish(finish).unwrap();
558
559            assert!(task.duration().unwrap() == Duration::milliseconds(milliseconds).try_into().unwrap());
560        }
561
562        #[test]
563        fn task_times_stay_none_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
564            let mut task = Task::new("World domination");
565
566            let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
567            task.edit_duration(duration);
568            assert!(task.finish().is_none());
569            assert!(task.start().is_none());
570        }
571
572        #[test]
573        fn finish_time_is_properly_set_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
574            let start = Utc::now();
575            let mut task = Task::new("World domination");
576
577            task.edit_start(start).unwrap();
578            let duration = Duration::milliseconds(milliseconds).try_into().unwrap();
579            task.edit_duration(duration);
580            assert!(task.finish().unwrap() == start + *duration);
581        }
582
583        #[test]
584        fn finish_time_is_properly_pushed_ahead_when_adding_duration(milliseconds in 0..MAX_TEST_MS) {
585            let start = Utc::now();
586            let finish = start + Duration::milliseconds(milliseconds);
587            let mut task = Task::new("World domination");
588
589            task.edit_start(start).unwrap();
590            task.edit_finish(finish).unwrap();
591
592            let duration = Duration::milliseconds(milliseconds + 1).try_into().unwrap();
593            task.edit_duration(duration);
594            assert!(task.finish().unwrap() == start + *duration);
595        }
596
597
598        #[test]
599        fn start_time_is_properly_pushed_back_when_adding_earlier_finish_time(milliseconds in 0..MAX_TEST_MS) {
600            let start = Utc::now();
601            let finish = start - Duration::milliseconds(milliseconds);
602            let mut task = Task::new("World domination");
603
604            task.edit_start(start).unwrap();
605            task.edit_finish(finish).unwrap();
606
607            assert!(task.start().unwrap() == task.finish().unwrap());
608        }
609    }
610
611    #[test]
612    fn edit_start_clamps_finish_when_start_after_finish() {
613        let finish = Utc::now();
614        let start = finish + Duration::milliseconds(1);
615        let mut task = Task::new("World domination");
616
617        task.edit_finish(finish).unwrap();
618        task.edit_start(start).unwrap();
619
620        assert_eq!(task.finish(), Some(start));
621        assert_eq!(
622            task.duration(),
623            Some(Duration::milliseconds(0).try_into().unwrap())
624        );
625    }
626
627    proptest! {
628        #[test]
629        fn toggle_completed_is_self_inverse(mut task in task_strategy()) {
630            let original = task.completed();
631            task.toggle_completed();
632            assert_eq!(task.completed(), !original);
633            task.toggle_completed();
634            assert_eq!(task.completed(), original);
635        }
636
637        #[test]
638        fn assign_increments_count(mut task in task_strategy()) {
639            let count = task.assignments().count();
640            task.assign(Uuid::new_v4(), nz(1));
641            assert_eq!(task.assignments().count(), count + 1);
642        }
643
644        #[test]
645        fn edit_name_roundtrip(mut task in task_strategy(), name in ".*") {
646            task.edit_name(&name);
647            assert_eq!(task.name(), &name);
648        }
649
650        #[test]
651        fn start_without_finish_or_duration(start_millis in 0..MAX_TEST_MS) {
652            let start = Utc::now() + chrono::Duration::milliseconds(start_millis);
653            let mut task = Task::new("test");
654            task.edit_start(start).unwrap();
655            assert!(task.finish().is_none());
656            assert!(task.duration().is_none());
657        }
658
659        #[test]
660        fn edit_start_and_duration_sets_finish(milliseconds in 0..MAX_TEST_MS) {
661            let start = Utc::now();
662            let mut task = Task::new("test");
663            task.edit_start(start).unwrap();
664            let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
665            task.edit_duration(duration);
666            assert_eq!(task.finish(), Some(start + *duration));
667        }
668
669        #[test]
670        fn clear_description_sets_to_none(mut task in task_strategy(), desc in ".*") {
671            task.edit_description(&desc);
672            assert_eq!(task.description(), Some(desc.as_str()));
673            task.clear_description();
674            assert!(task.description().is_none());
675        }
676
677        #[test]
678        fn unassign_works_correctly(mut task in task_strategy()) {
679            let a = Uuid::new_v4();
680            let b = Uuid::new_v4();
681            assert!(task.unassign(a).is_none());
682            task.assign(a, nz(1));
683            task.assign(b, nz(1));
684            assert_eq!(task.assignments().count(), 2);
685            assert_eq!(task.unassign(a), Some(1));
686            assert_eq!(task.assignments().count(), 1);
687            assert!(task.unassign(a).is_none());
688        }
689
690        #[test]
691        fn assigning_the_same_resource_twice_replaces_it(mut task in task_strategy()) {
692            let id = Uuid::new_v4();
693            let before = task.assignments().count();
694            task.assign(id, nz(2));
695            task.assign(id, nz(1));
696            assert_eq!(task.assignments().count(), before + 1);
697            assert_eq!(task.assignments().find(|(r, _)| *r == id), Some((id, 1)));
698        }
699
700        #[test]
701        fn edit_start_with_duration_infers_finish(milliseconds in 0..MAX_TEST_MS) {
702            let start = Utc::now();
703            let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
704            let mut task = Task::new("World domination");
705            task.edit_duration(duration);
706            task.edit_start(start).unwrap();
707            assert_eq!(task.finish(), Some(start + *duration));
708        }
709
710        #[test]
711        fn edit_finish_with_duration_infers_start(milliseconds in 0..MAX_TEST_MS) {
712            let finish = Utc::now();
713            let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
714            let mut task = Task::new("World domination");
715            task.edit_duration(duration);
716            task.edit_finish(finish).unwrap();
717            assert_eq!(task.start(), Some(finish - *duration));
718        }
719
720        #[test]
721        fn edit_duration_with_finish_infers_start(milliseconds in 0..MAX_TEST_MS) {
722            let finish = Utc::now();
723            let duration = chrono::Duration::milliseconds(milliseconds).try_into().unwrap();
724            let mut task = Task::new("World domination");
725            task.edit_finish(finish).unwrap();
726            task.edit_duration(duration);
727            assert_eq!(task.start(), Some(finish - *duration));
728        }
729
730        #[test]
731        fn cost_rate_only(rate in 0u64..1000, hours in 0i64..1000) {
732            let mut task = Task::new("test");
733            task.edit_duration(Duration::hours(hours).try_into().unwrap());
734
735            let resource = Resource::new("Intern".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
736            task.assign(resource.id(), nz(1));
737
738            let expected = Money::from_minor_units(rate * u64::try_from(hours).unwrap(), Currency::EUR);
739            assert_eq!(task.cost(&pool([resource])), expected.into());
740        }
741
742        #[test]
743        fn cost_scales_with_assignment_quantity(quantity in 1u32..1000, rate in 0u64..1000) {
744            let mut task = Task::new("test");
745            task.edit_duration(Duration::hours(1).try_into().unwrap());
746
747            let resource = Resource::new("Crowbar".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
748            task.assign(resource.id(), nz(quantity));
749
750            let expected = Money::from_minor_units(u64::from(quantity) * rate, Currency::EUR);
751            assert_eq!(task.cost(&pool([resource])), expected.into());
752        }
753
754        #[test]
755        fn cost_sums_every_assignment(rate in 0u64..500, hours in 0i64..500) {
756            let mut task = Task::new("test");
757            task.edit_duration(Duration::hours(hours).try_into().unwrap());
758
759            let intern = Resource::new("Intern".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
760            task.assign(intern.id(), nz(1));
761
762            let server = Resource::new("Server".parse().unwrap()).at_hourly_rate(Money::from_minor_units(rate, Currency::EUR));
763            task.assign(server.id(), nz(1));
764
765            let hours_u64 = u64::try_from(hours).unwrap();
766            // Both resources are in EUR, so it's one entry.
767            let expected = Money::from_minor_units(2 * rate * hours_u64, Currency::EUR);
768            assert_eq!(task.cost(&pool([intern, server])), expected.into());
769        }
770    }
771
772    #[test]
773    fn assignments_are_iterated_in_insertion_order() {
774        let mut task = Task::new("test");
775        let a = Uuid::new_v4();
776        let b = Uuid::new_v4();
777        let c = Uuid::new_v4();
778        task.assign(a, nz(1));
779        task.assign(b, nz(1));
780        task.assign(c, nz(1));
781
782        let ids: Vec<_> = task.assignments().map(|(id, _)| id).collect();
783        assert_eq!(ids, vec![a, b, c]);
784    }
785
786    #[test]
787    fn cost_is_zero_with_no_assignments() {
788        let mut task = Task::new("test");
789        task.edit_duration(Duration::hours(10).try_into().unwrap());
790        assert!(task.cost(&pool([])).is_empty());
791    }
792
793    #[test]
794    fn cost_is_zero_when_the_resource_has_no_rate() {
795        let mut task = Task::new("test");
796        task.edit_duration(Duration::hours(10).try_into().unwrap());
797        let resource = Resource::new("Stimpack".parse().unwrap());
798        task.assign(resource.id(), nz(1));
799        assert!(task.cost(&pool([resource])).is_empty());
800    }
801
802    #[test]
803    fn cost_is_zero_when_duration_is_unset() {
804        let mut task = Task::new("test");
805        let resource = Resource::new("Intern".parse().unwrap())
806            .at_hourly_rate(Money::from_minor_units(50, Currency::EUR));
807        task.assign(resource.id(), nz(1));
808        assert!(task.cost(&pool([resource])).is_empty());
809    }
810
811    #[test]
812    fn cost_ignores_an_assignment_not_in_the_pool() {
813        let mut task = Task::new("test");
814        task.edit_duration(Duration::hours(10).try_into().unwrap());
815        task.assign(Uuid::new_v4(), nz(1));
816        assert!(task.cost(&pool([])).is_empty());
817    }
818
819    #[test]
820    fn cost_does_not_count_purchases() {
821        use crate::resources::Purchase;
822
823        let mut task = Task::new("test");
824        task.edit_duration(Duration::hours(10).try_into().unwrap());
825        let mut resource = Resource::new("Stimpack".parse().unwrap());
826        resource.add_purchase(
827            Purchase::builder()
828                .quantity(40)
829                .unit_price(Money::from_minor_units(500, Currency::EUR))
830                .build(),
831        );
832        task.assign(resource.id(), nz(40));
833        assert!(task.cost(&pool([resource])).is_empty());
834    }
835
836    #[test]
837    fn cost_does_not_saturate_for_a_realistic_multi_year_task() {
838        // An ~8-year task (70,000 hours) at a high hourly rate: realistic for a
839        // long-running project, not a pathological input.
840        let mut task = Task::new("test");
841        task.edit_duration(Duration::hours(70_000).try_into().unwrap());
842        let resource = Resource::new("Intern".parse().unwrap())
843            .at_hourly_rate(Money::from_minor_units(u64::from(u16::MAX), Currency::EUR));
844        task.assign(resource.id(), nz(1));
845
846        assert_eq!(
847            task.cost(&pool([resource])).in_currency(Currency::EUR),
848            Some(Money::from_minor_units(
849                u64::from(u16::MAX) * 70_000,
850                Currency::EUR
851            )),
852        );
853    }
854
855    #[test]
856    fn cost_keeps_different_currencies_as_separate_entries() {
857        let mut task = Task::new("test");
858        task.edit_duration(Duration::hours(10).try_into().unwrap());
859
860        let intern = Resource::new("Intern".parse().unwrap())
861            .at_hourly_rate(Money::from_minor_units(10, Currency::EUR));
862        task.assign(intern.id(), nz(1));
863
864        let server = Resource::new("Server".parse().unwrap())
865            .at_hourly_rate(Money::from_minor_units(50, Currency::USD));
866        task.assign(server.id(), nz(1));
867
868        let cost = task.cost(&pool([intern, server]));
869        let entries: Vec<_> = cost.iter().collect();
870        assert_eq!(entries.len(), 2);
871        assert!(entries.contains(&Money::from_minor_units(100, Currency::EUR)));
872        assert!(entries.contains(&Money::from_minor_units(500, Currency::USD)));
873    }
874}
875
876#[cfg(all(test, feature = "serde"))]
877mod serde_tests {
878    use proptest::prelude::*;
879
880    use crate::task::Task;
881    use crate::task::test_utils::task_strategy;
882
883    proptest! {
884        #[test]
885        fn serde_roundtrip(task in task_strategy()) {
886            let json = serde_json::to_string(&task).unwrap();
887            let deserialized: Task = serde_json::from_str(&json).unwrap();
888            let json2 = serde_json::to_string(&deserialized).unwrap();
889            let v1: serde_json::Value = serde_json::from_str(&json).unwrap();
890            let v2: serde_json::Value = serde_json::from_str(&json2).unwrap();
891            assert_eq!(v1, v2, "serde roundtrip must produce equivalent JSON");
892        }
893    }
894}