| 724 |
724 |
|
/// Estimated duration in minutes.
|
| 725 |
725 |
|
pub estimated_minutes: Option<i32>,
|
| 726 |
726 |
|
}
|
|
727 |
+ |
|
|
728 |
+ |
#[cfg(test)]
|
|
729 |
+ |
mod tests {
|
|
730 |
+ |
use super::*;
|
|
731 |
+ |
use crate::id_types::{SubtaskId, TaskId};
|
|
732 |
+ |
use crate::models::shared::{CssClass, DbValue, Recurrence};
|
|
733 |
+ |
use chrono::{Duration, Utc};
|
|
734 |
+ |
use std::str::FromStr;
|
|
735 |
+ |
|
|
736 |
+ |
/// A baseline task with everything empty/defaulted; mutate fields per test.
|
|
737 |
+ |
fn task() -> Task {
|
|
738 |
+ |
Task {
|
|
739 |
+ |
id: TaskId::new(),
|
|
740 |
+ |
project_id: None,
|
|
741 |
+ |
project_name: None,
|
|
742 |
+ |
milestone_id: None,
|
|
743 |
+ |
contact_id: None,
|
|
744 |
+ |
contact_name: None,
|
|
745 |
+ |
description: "Test task".to_string(),
|
|
746 |
+ |
status: TaskStatus::Pending,
|
|
747 |
+ |
priority: Priority::Medium,
|
|
748 |
+ |
due: None,
|
|
749 |
+ |
tags: Vec::new(),
|
|
750 |
+ |
urgency: 0.0,
|
|
751 |
+ |
recurrence: Recurrence::None,
|
|
752 |
+ |
recurrence_rule: None,
|
|
753 |
+ |
recurrence_parent_id: None,
|
|
754 |
+ |
source_email_id: None,
|
|
755 |
+ |
snoozed_until: None,
|
|
756 |
+ |
waiting_for_response: false,
|
|
757 |
+ |
waiting_since: None,
|
|
758 |
+ |
expected_response_date: None,
|
|
759 |
+ |
scheduled_start: None,
|
|
760 |
+ |
scheduled_duration: None,
|
|
761 |
+ |
annotations: Vec::new(),
|
|
762 |
+ |
subtasks: Vec::new(),
|
|
763 |
+ |
estimated_minutes: None,
|
|
764 |
+ |
actual_minutes: 0,
|
|
765 |
+ |
active_session: None,
|
|
766 |
+ |
created_at: Utc::now(),
|
|
767 |
+ |
completed_at: None,
|
|
768 |
+ |
is_focus: false,
|
|
769 |
+ |
focus_set_at: None,
|
|
770 |
+ |
}
|
|
771 |
+ |
}
|
|
772 |
+ |
|
|
773 |
+ |
fn subtask(is_completed: bool) -> Subtask {
|
|
774 |
+ |
Subtask {
|
|
775 |
+ |
id: SubtaskId::new(),
|
|
776 |
+ |
task_id: TaskId::new(),
|
|
777 |
+ |
text: "sub".to_string(),
|
|
778 |
+ |
linked_task_id: None,
|
|
779 |
+ |
is_completed,
|
|
780 |
+ |
position: 0,
|
|
781 |
+ |
}
|
|
782 |
+ |
}
|
|
783 |
+ |
|
|
784 |
+ |
// ---- TaskStatus ----
|
|
785 |
+ |
|
|
786 |
+ |
#[test]
|
|
787 |
+ |
fn task_status_as_str_and_css_and_db() {
|
|
788 |
+ |
assert_eq!(TaskStatus::Started.as_str(), "Started");
|
|
789 |
+ |
assert_eq!(TaskStatus::Completed.css_class(), "task-completed");
|
|
790 |
+ |
assert_eq!(TaskStatus::Deleted.db_value(), "Deleted");
|
|
791 |
+ |
assert_eq!(TaskStatus::default(), TaskStatus::Pending);
|
|
792 |
+ |
}
|
|
793 |
+ |
|
|
794 |
+ |
#[test]
|
|
795 |
+ |
fn task_status_from_str() {
|
|
796 |
+ |
assert_eq!(TaskStatus::from_str("Completed").unwrap(), TaskStatus::Completed);
|
|
797 |
+ |
assert!(TaskStatus::from_str("nonsense").is_err());
|
|
798 |
+ |
}
|
|
799 |
+ |
|
|
800 |
+ |
// ---- Priority ----
|
|
801 |
+ |
|
|
802 |
+ |
#[test]
|
|
803 |
+ |
fn priority_as_str_is_short_form() {
|
|
804 |
+ |
assert_eq!(Priority::High.as_str(), "H");
|
|
805 |
+ |
assert_eq!(Priority::Medium.as_str(), "M");
|
|
806 |
+ |
assert_eq!(Priority::Low.as_str(), "L");
|
|
807 |
+ |
}
|
|
808 |
+ |
|
|
809 |
+ |
#[test]
|
|
810 |
+ |
fn priority_from_str_or_default_accepts_variants() {
|
|
811 |
+ |
for s in ["High", "H", "high", "h"] {
|
|
812 |
+ |
assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}");
|
|
813 |
+ |
}
|
|
814 |
+ |
for s in ["Low", "L", "low", "l"] {
|
|
815 |
+ |
assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}");
|
|
816 |
+ |
}
|
|
817 |
+ |
for s in ["Medium", "M", "Med", "med", "m"] {
|
|
818 |
+ |
assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}");
|
|
819 |
+ |
}
|
|
820 |
+ |
}
|
|
821 |
+ |
|
|
822 |
+ |
#[test]
|
|
823 |
+ |
fn priority_from_str_or_default_falls_back_to_medium() {
|
|
824 |
+ |
assert_eq!(Priority::from_str_or_default(""), Priority::Medium);
|
|
825 |
+ |
assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium);
|
|
826 |
+ |
assert_eq!(Priority::default(), Priority::Medium);
|
|
827 |
+ |
}
|
|
828 |
+ |
|
|
829 |
+ |
#[test]
|
|
830 |
+ |
fn priority_db_value_is_long_form() {
|
|
831 |
+ |
assert_eq!(Priority::High.db_value(), "High");
|
|
832 |
+ |
assert_eq!(Priority::Low.css_class(), "priority-low");
|
|
833 |
+ |
}
|
|
834 |
+ |
|
|
835 |
+ |
// ---- TaskSortColumn ----
|
|
836 |
+ |
|
|
837 |
+ |
#[test]
|
|
838 |
+ |
fn sort_column_parses_case_insensitively() {
|
|
839 |
+ |
assert_eq!(TaskSortColumn::from_str_or_default("DUE"), TaskSortColumn::Due);
|
|
840 |
+ |
assert_eq!(TaskSortColumn::from_str_or_default("Project"), TaskSortColumn::Project);
|
|
841 |
+ |
assert_eq!(TaskSortColumn::from_str_or_default("priority"), TaskSortColumn::Priority);
|
|
842 |
+ |
// unknown falls back to the default (Urgency)
|
|
843 |
+ |
assert_eq!(TaskSortColumn::from_str_or_default("xyz"), TaskSortColumn::Urgency);
|
|
844 |
+ |
assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency);
|
|
845 |
+ |
}
|
|
846 |
+ |
|
|
847 |
+ |
// ---- due_formatted ----
|
|
848 |
+ |
|
|
849 |
+ |
#[test]
|
|
850 |
+ |
fn due_formatted_none_is_dash() {
|
|
851 |
+ |
assert_eq!(task().due_formatted(), "-");
|
|
852 |
+ |
}
|
|
853 |
+ |
|
|
854 |
+ |
#[test]
|
|
855 |
+ |
fn due_formatted_relative_buckets() {
|
|
856 |
+ |
let mut t = task();
|
|
857 |
+ |
|
|
858 |
+ |
t.due = Some(Utc::now());
|
|
859 |
+ |
assert_eq!(t.due_formatted(), "today");
|
|
860 |
+ |
|
|
861 |
+ |
t.due = Some(Utc::now() + Duration::days(1));
|
|
862 |
+ |
assert_eq!(t.due_formatted(), "tomorrow");
|
|
863 |
+ |
|
|
864 |
+ |
t.due = Some(Utc::now() + Duration::days(3));
|
|
865 |
+ |
assert_eq!(t.due_formatted(), "+3d");
|
|
866 |
+ |
|
|
867 |
+ |
t.due = Some(Utc::now() - Duration::days(2));
|
|
868 |
+ |
assert_eq!(t.due_formatted(), "2d ago");
|
|
869 |
+ |
}
|
|
870 |
+ |
|
|
871 |
+ |
#[test]
|
|
872 |
+ |
fn due_formatted_far_future_is_iso_date() {
|
|
873 |
+ |
let mut t = task();
|
|
874 |
+ |
let far = Utc::now() + Duration::days(30);
|
|
875 |
+ |
t.due = Some(far);
|
|
876 |
+ |
assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string());
|
|
877 |
+ |
}
|
|
878 |
+ |
|
|
879 |
+ |
// ---- overdue / urgency_class ----
|
|
880 |
+ |
|
|
881 |
+ |
#[test]
|
|
882 |
+ |
fn is_overdue_reads_due_vs_now() {
|
|
883 |
+ |
let mut t = task();
|
|
884 |
+ |
assert!(!t.is_overdue(), "no due date is never overdue");
|
|
885 |
+ |
t.due = Some(Utc::now() - Duration::hours(1));
|
|
886 |
+ |
assert!(t.is_overdue());
|
|
887 |
+ |
t.due = Some(Utc::now() + Duration::hours(1));
|
|
888 |
+ |
assert!(!t.is_overdue());
|
|
889 |
+ |
}
|
|
890 |
+ |
|
|
891 |
+ |
#[test]
|
|
892 |
+ |
fn urgency_class_thresholds() {
|
|
893 |
+ |
let mut t = task();
|
|
894 |
+ |
t.urgency = 9.0;
|
|
895 |
+ |
assert_eq!(t.urgency_class(), "urgency-high");
|
|
896 |
+ |
t.urgency = 5.0;
|
|
897 |
+ |
assert_eq!(t.urgency_class(), "urgency-medium");
|
|
898 |
+ |
t.urgency = 4.9;
|
|
899 |
+ |
assert_eq!(t.urgency_class(), "urgency-low");
|
|
900 |
+ |
}
|
|
901 |
+ |
|
|
902 |
+ |
#[test]
|
|
903 |
+ |
fn urgency_class_overdue_wins_over_score() {
|
|
904 |
+ |
let mut t = task();
|
|
905 |
+ |
t.urgency = 9.9; // would be "high"
|
|
906 |
+ |
t.due = Some(Utc::now() - Duration::days(1));
|
|
907 |
+ |
assert_eq!(t.urgency_class(), "urgency-overdue");
|
|
908 |
+ |
}
|
|
909 |
+ |
|
|
910 |
+ |
#[test]
|
|
911 |
+ |
fn urgency_formatted_one_decimal() {
|
|
912 |
+ |
let mut t = task();
|
|
913 |
+ |
t.urgency = 8.34;
|
|
914 |
+ |
assert_eq!(t.urgency_formatted(), "8.3");
|
|
915 |
+ |
t.urgency = 0.0;
|
|
916 |
+ |
assert_eq!(t.urgency_formatted(), "0.0");
|
|
917 |
+ |
}
|
|
918 |
+ |
|
|
919 |
+ |
#[test]
|
|
920 |
+ |
fn due_timestamp_defaults_to_zero() {
|
|
921 |
+ |
let mut t = task();
|
|
922 |
+ |
assert_eq!(t.due_timestamp(), 0);
|
|
923 |
+ |
let d = Utc::now();
|
|
924 |
+ |
t.due = Some(d);
|
|
925 |
+ |
assert_eq!(t.due_timestamp(), d.timestamp());
|
|
926 |
+ |
}
|
|
927 |
+ |
|
|
928 |
+ |
// ---- subtasks / annotations ----
|
|
929 |
+ |
|
|
930 |
+ |
#[test]
|
|
931 |
+ |
fn subtask_counts_and_progress() {
|
|
932 |
+ |
let mut t = task();
|
|
933 |
+ |
assert!(!t.has_subtasks());
|
|
934 |
+ |
assert_eq!(t.subtasks_progress(), "0/0");
|
|
935 |
+ |
t.subtasks = vec![subtask(true), subtask(false), subtask(true)];
|
|
936 |
+ |
assert!(t.has_subtasks());
|
|
937 |
+ |
assert_eq!(t.subtask_count(), 3);
|
|
938 |
+ |
assert_eq!(t.subtasks_completed(), 2);
|
|
939 |
+ |
assert_eq!(t.subtasks_progress(), "2/3");
|
|
940 |
+ |
}
|
|
941 |
+ |
|
|
942 |
+ |
#[test]
|
|
943 |
+ |
fn project_name_fallbacks() {
|
|
944 |
+ |
let mut t = task();
|
|
945 |
+ |
assert_eq!(t.project_name_or_dash(), "-");
|
|
946 |
+ |
assert_eq!(t.project_name_or_empty(), "");
|
|
947 |
+ |
t.project_name = Some("Website".to_string());
|
|
948 |
+ |
assert_eq!(t.project_name_or_dash(), "Website");
|
|
949 |
+ |
assert_eq!(t.project_name_or_empty(), "Website");
|
|
950 |
+ |
}
|
|
951 |
+ |
|
|
952 |
+ |
#[test]
|
|
953 |
+ |
fn recurrence_and_source_flags() {
|
|
954 |
+ |
let mut t = task();
|
|
955 |
+ |
assert!(!t.has_recurrence());
|
|
956 |
+ |
assert!(t.effective_recurrence_rule().is_none());
|
|
957 |
+ |
t.recurrence = Recurrence::Weekly;
|
|
958 |
+ |
assert!(t.has_recurrence());
|
|
959 |
+ |
assert!(!t.has_source_email());
|
|
960 |
+ |
}
|
|
961 |
+ |
|
|
962 |
+ |
// ---- snooze / waiting / focus ----
|
|
963 |
+ |
|
|
964 |
+ |
#[test]
|
|
965 |
+ |
fn is_snoozed_only_when_future() {
|
|
966 |
+ |
let mut t = task();
|
|
967 |
+ |
assert!(!t.is_snoozed());
|
|
968 |
+ |
t.snoozed_until = Some(Utc::now() + Duration::hours(1));
|
|
969 |
+ |
assert!(t.is_snoozed());
|
|
970 |
+ |
t.snoozed_until = Some(Utc::now() - Duration::hours(1));
|
|
971 |
+ |
assert!(!t.is_snoozed());
|
|
972 |
+ |
}
|
|
973 |
+ |
|
|
974 |
+ |
#[test]
|
|
975 |
+ |
fn response_overdue_requires_waiting_and_past_date() {
|
|
976 |
+ |
let mut t = task();
|
|
977 |
+ |
assert!(!t.is_response_overdue());
|
|
978 |
+ |
// past expected date but not waiting -> false
|
|
979 |
+ |
t.expected_response_date = Some(Utc::now() - Duration::days(1));
|
|
980 |
+ |
assert!(!t.is_response_overdue());
|
|
981 |
+ |
// waiting + past -> true
|
|
982 |
+ |
t.waiting_for_response = true;
|
|
983 |
+ |
assert!(t.is_waiting());
|
|
984 |
+ |
assert!(t.is_response_overdue());
|
|
985 |
+ |
// waiting but future -> false
|
|
986 |
+ |
t.expected_response_date = Some(Utc::now() + Duration::days(1));
|
|
987 |
+ |
assert!(!t.is_response_overdue());
|
|
988 |
+ |
}
|
|
989 |
+ |
|
|
990 |
+ |
#[test]
|
|
991 |
+ |
fn is_focused_reads_flag() {
|
|
992 |
+ |
let mut t = task();
|
|
993 |
+ |
assert!(!t.is_focused());
|
|
994 |
+ |
t.is_focus = true;
|
|
995 |
+ |
assert!(t.is_focused());
|
|
996 |
+ |
}
|
|
997 |
+ |
|
|
998 |
+ |
// ---- time progress ----
|
|
999 |
+ |
|
|
1000 |
+ |
#[test]
|
|
1001 |
+ |
fn time_progress_none_without_estimate() {
|
|
1002 |
+ |
assert_eq!(task().time_progress(), None);
|
|
1003 |
+ |
}
|
|
1004 |
+ |
|
|
1005 |
+ |
#[test]
|
|
1006 |
+ |
fn time_progress_percentage_and_clamp() {
|
|
1007 |
+ |
let mut t = task();
|
|
1008 |
+ |
t.estimated_minutes = Some(100);
|
|
1009 |
+ |
t.actual_minutes = 50;
|
|
1010 |
+ |
assert_eq!(t.time_progress(), Some(50));
|
|
1011 |
+ |
// clamps at 100 even when over
|
|
1012 |
+ |
t.actual_minutes = 250;
|
|
1013 |
+ |
assert_eq!(t.time_progress(), Some(100));
|
|
1014 |
+ |
// zero estimate is treated as 0%, never divides by zero
|
|
1015 |
+ |
t.estimated_minutes = Some(0);
|
|
1016 |
+ |
assert_eq!(t.time_progress(), Some(0));
|
|
1017 |
+ |
}
|
|
1018 |
+ |
|
|
1019 |
+ |
#[test]
|
|
1020 |
+ |
fn is_over_estimate_rules() {
|
|
1021 |
+ |
let mut t = task();
|
|
1022 |
+ |
assert!(!t.is_over_estimate(), "no estimate -> not over");
|
|
1023 |
+ |
t.estimated_minutes = Some(60);
|
|
1024 |
+ |
t.actual_minutes = 61;
|
|
1025 |
+ |
assert!(t.is_over_estimate());
|
|
1026 |
+ |
t.actual_minutes = 60;
|
|
1027 |
+ |
assert!(!t.is_over_estimate(), "equal is not over");
|
|
1028 |
+ |
t.estimated_minutes = Some(0);
|
|
1029 |
+ |
t.actual_minutes = 5;
|
|
1030 |
+ |
assert!(!t.is_over_estimate(), "zero estimate is never over");
|
|
1031 |
+ |
}
|
|
1032 |
+ |
|
|
1033 |
+ |
#[test]
|
|
1034 |
+ |
fn has_active_timer_reads_session() {
|
|
1035 |
+ |
assert!(!task().has_active_timer());
|
|
1036 |
+ |
}
|
|
1037 |
+ |
|
|
1038 |
+ |
// ---- NewTaskBuilder ----
|
|
1039 |
+ |
|
|
1040 |
+ |
#[test]
|
|
1041 |
+ |
fn builder_defaults() {
|
|
1042 |
+ |
let nt = NewTask::builder("Write tests").build();
|
|
1043 |
+ |
assert_eq!(nt.description, "Write tests");
|
|
1044 |
+ |
assert_eq!(nt.priority, Priority::Medium);
|
|
1045 |
+ |
assert_eq!(nt.urgency, 0.0);
|
|
1046 |
+ |
assert_eq!(nt.recurrence, Recurrence::None);
|
|
1047 |
+ |
assert!(nt.tags.is_empty());
|
|
1048 |
+ |
assert!(nt.due.is_none());
|
|
1049 |
+ |
assert!(nt.estimated_minutes.is_none());
|
|
1050 |
+ |
}
|
|
1051 |
+ |
|
|
1052 |
+ |
#[test]
|
|
1053 |
+ |
fn builder_sets_fields() {
|
|
1054 |
+ |
let due = Utc::now();
|
|
1055 |
+ |
let nt = NewTask::builder("Fix bug")
|
|
1056 |
+ |
.priority(Priority::High)
|
|
1057 |
+ |
.due(due)
|
|
1058 |
+ |
.tag("urgent")
|
|
1059 |
+ |
.tag("backend")
|
|
1060 |
+ |
.urgency(8.0)
|
|
1061 |
+ |
.estimated_minutes(45)
|
|
1062 |
+ |
.recurrence(Recurrence::Daily)
|
|
1063 |
+ |
.build();
|
|
1064 |
+ |
assert_eq!(nt.priority, Priority::High);
|
|
1065 |
+ |
assert_eq!(nt.due, Some(due));
|
|
1066 |
+ |
assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]);
|
|
1067 |
+ |
assert_eq!(nt.urgency, 8.0);
|
|
1068 |
+ |
assert_eq!(nt.estimated_minutes, Some(45));
|
|
1069 |
+ |
assert_eq!(nt.recurrence, Recurrence::Daily);
|
|
1070 |
+ |
}
|
|
1071 |
+ |
|
|
1072 |
+ |
#[test]
|
|
1073 |
+ |
fn builder_tags_replaces_accumulated() {
|
|
1074 |
+ |
let nt = NewTask::builder("t")
|
|
1075 |
+ |
.tag("a")
|
|
1076 |
+ |
.tags(vec!["x".to_string(), "y".to_string()])
|
|
1077 |
+ |
.build();
|
|
1078 |
+ |
assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]);
|
|
1079 |
+ |
}
|
|
1080 |
+ |
}
|