//! Tests for [`super`]. use super::*; use crate::id_types::{SubtaskId, TaskId}; use crate::models::shared::{CssClass, DbValue, Recurrence}; use chrono::{Duration, Utc}; use std::str::FromStr; use super::test_task as task; fn subtask(is_completed: bool) -> Subtask { Subtask { id: SubtaskId::new(), task_id: TaskId::new(), text: "sub".to_string(), linked_task_id: None, is_completed, position: 0, } } // TaskStatus #[test] fn task_status_as_str_and_css_and_db() { assert_eq!(TaskStatus::Started.as_str(), "Started"); assert_eq!(TaskStatus::Completed.css_class(), "task-completed"); assert_eq!(TaskStatus::Deleted.db_value(), "Deleted"); assert_eq!(TaskStatus::default(), TaskStatus::Pending); } #[test] fn task_status_from_str() { assert_eq!( TaskStatus::from_str("Completed").unwrap(), TaskStatus::Completed ); assert!(TaskStatus::from_str("nonsense").is_err()); } // Priority #[test] fn priority_as_str_is_short_form() { assert_eq!(Priority::High.as_str(), "H"); assert_eq!(Priority::Medium.as_str(), "M"); assert_eq!(Priority::Low.as_str(), "L"); } #[test] fn priority_from_str_or_default_accepts_variants() { for s in ["High", "H", "high", "h"] { assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}"); } for s in ["Low", "L", "low", "l"] { assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}"); } for s in ["Medium", "M", "Med", "med", "m"] { assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}"); } } #[test] fn priority_from_str_or_default_falls_back_to_medium() { assert_eq!(Priority::from_str_or_default(""), Priority::Medium); assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium); assert_eq!(Priority::default(), Priority::Medium); } #[test] fn priority_db_value_is_long_form() { assert_eq!(Priority::High.db_value(), "High"); assert_eq!(Priority::Low.css_class(), "priority-low"); } // TaskSortColumn #[test] fn sort_column_parses_case_insensitively() { assert_eq!( TaskSortColumn::from_str_or_default("DUE"), TaskSortColumn::Due ); assert_eq!( TaskSortColumn::from_str_or_default("Project"), TaskSortColumn::Project ); assert_eq!( TaskSortColumn::from_str_or_default("priority"), TaskSortColumn::Priority ); // unknown falls back to the default (Urgency) assert_eq!( TaskSortColumn::from_str_or_default("xyz"), TaskSortColumn::Urgency ); assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency); } // due_formatted #[test] fn due_formatted_none_is_dash() { assert_eq!(task().due_formatted(), "-"); } #[test] fn due_formatted_relative_buckets() { let mut t = task(); t.due = Some(Utc::now()); assert_eq!(t.due_formatted(), "today"); t.due = Some(Utc::now() + Duration::days(1)); assert_eq!(t.due_formatted(), "tomorrow"); t.due = Some(Utc::now() + Duration::days(3)); assert_eq!(t.due_formatted(), "+3d"); t.due = Some(Utc::now() - Duration::days(2)); assert_eq!(t.due_formatted(), "2d ago"); } #[test] fn due_formatted_far_future_is_iso_date() { let mut t = task(); let far = Utc::now() + Duration::days(30); t.due = Some(far); assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string()); } // overdue / urgency_class #[test] fn is_overdue_reads_due_vs_now() { let mut t = task(); assert!(!t.is_overdue(), "no due date is never overdue"); t.due = Some(Utc::now() - Duration::hours(1)); assert!(t.is_overdue()); t.due = Some(Utc::now() + Duration::hours(1)); assert!(!t.is_overdue()); } #[test] fn urgency_class_thresholds() { let mut t = task(); t.urgency = 9.0; assert_eq!(t.urgency_class(), "urgency-high"); t.urgency = 5.0; assert_eq!(t.urgency_class(), "urgency-medium"); t.urgency = 4.9; assert_eq!(t.urgency_class(), "urgency-low"); } #[test] fn urgency_class_overdue_wins_over_score() { let mut t = task(); t.urgency = 9.9; // would be "high" t.due = Some(Utc::now() - Duration::days(1)); assert_eq!(t.urgency_class(), "urgency-overdue"); } #[test] fn urgency_formatted_one_decimal() { let mut t = task(); t.urgency = 8.34; assert_eq!(t.urgency_formatted(), "8.3"); t.urgency = 0.0; assert_eq!(t.urgency_formatted(), "0.0"); } #[test] fn due_timestamp_defaults_to_zero() { let mut t = task(); assert_eq!(t.due_timestamp(), 0); let d = Utc::now(); t.due = Some(d); assert_eq!(t.due_timestamp(), d.timestamp()); } // subtasks / annotations #[test] fn subtask_counts_and_progress() { let mut t = task(); assert!(!t.has_subtasks()); assert_eq!(t.subtasks_progress(), "0/0"); t.subtasks = vec![subtask(true), subtask(false), subtask(true)]; assert!(t.has_subtasks()); assert_eq!(t.subtask_count(), 3); assert_eq!(t.subtasks_completed(), 2); assert_eq!(t.subtasks_progress(), "2/3"); } #[test] fn project_name_fallbacks() { let mut t = task(); assert_eq!(t.project_name_or_dash(), "-"); assert_eq!(t.project_name_or_empty(), ""); t.project_name = Some("Website".to_string()); assert_eq!(t.project_name_or_dash(), "Website"); assert_eq!(t.project_name_or_empty(), "Website"); } fn token(t: &Task, reference: &str, state: TokenState, primary: bool, pos: i32) -> StatusToken { StatusToken { id: StatusToken::deterministic_id(t.id, TOKEN_KIND_COMMIT, reference), task_id: t.id, kind: TOKEN_KIND_COMMIT.to_string(), reference: reference.to_string(), state, is_primary: primary, position: pos, } } #[test] fn status_token_deterministic_id_is_stable_and_content_derived() { let tid = TaskId::new(); let a = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8"); let b = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8"); assert_eq!(a, b, "same task+kind+ref must yield the same id"); assert_ne!( a, StatusToken::deterministic_id(tid, "commit", "deox@a19f0011"), "ref differs" ); assert_ne!( a, StatusToken::deterministic_id(tid, "attachment", "deox@7c236fca8"), "kind differs" ); assert_ne!( a, StatusToken::deterministic_id(TaskId::new(), "commit", "deox@7c236fca8"), "task differs" ); assert_eq!(a.as_uuid().get_version_num(), 5); } #[test] fn primary_token_finds_the_flagged_one() { let mut t = task(); assert!(!t.has_status_tokens()); assert!(t.primary_token().is_none()); t.status_tokens = vec![ token(&t, "deox@aaa", TokenState::Pending, false, 0), token(&t, "deox@bbb", TokenState::Complete, true, 1), ]; assert!(t.has_status_tokens()); assert_eq!( t.primary_token().map(|c| c.reference.as_str()), Some("deox@bbb") ); } #[test] fn status_token_summary_rolls_up_states() { let mut t = task(); assert_eq!(t.status_token_summary(), "neutral"); t.status_tokens = vec![token(&t, "deox@aaa", TokenState::Pending, false, 0)]; assert_eq!(t.status_token_summary(), "pending"); t.status_tokens = vec![ token(&t, "deox@aaa", TokenState::Complete, false, 0), token(&t, "deox@bbb", TokenState::Pending, true, 1), ]; assert_eq!( t.status_token_summary(), "pending", "any pending keeps the rollup pending" ); t.status_tokens = vec![ token(&t, "deox@aaa", TokenState::Complete, false, 0), token(&t, "deox@bbb", TokenState::Complete, true, 1), ]; assert_eq!( t.status_token_summary(), "complete", "all complete rolls up complete" ); } #[test] fn recurrence_and_source_flags() { let mut t = task(); assert!(!t.has_recurrence()); assert!(t.effective_recurrence_rule().is_none()); t.recurrence = Recurrence::Weekly; assert!(t.has_recurrence()); assert!(!t.has_source_email()); } // snooze / waiting / focus #[test] fn is_snoozed_only_when_future() { let mut t = task(); assert!(!t.is_snoozed()); t.snoozed_until = Some(Utc::now() + Duration::hours(1)); assert!(t.is_snoozed()); t.snoozed_until = Some(Utc::now() - Duration::hours(1)); assert!(!t.is_snoozed()); } #[test] fn response_overdue_requires_waiting_and_past_date() { let mut t = task(); assert!(!t.is_response_overdue()); // past expected date but not waiting -> false t.expected_response_date = Some(Utc::now() - Duration::days(1)); assert!(!t.is_response_overdue()); // waiting + past -> true t.waiting_for_response = true; assert!(t.is_waiting()); assert!(t.is_response_overdue()); // waiting but future -> false t.expected_response_date = Some(Utc::now() + Duration::days(1)); assert!(!t.is_response_overdue()); } #[test] fn is_focused_reads_flag() { let mut t = task(); assert!(!t.is_focused()); t.is_focus = true; assert!(t.is_focused()); } // time progress #[test] fn time_progress_none_without_estimate() { assert_eq!(task().time_progress(), None); } #[test] fn time_progress_percentage_and_clamp() { let mut t = task(); t.estimated_minutes = Some(100); t.actual_minutes = 50; assert_eq!(t.time_progress(), Some(50)); // clamps at 100 even when over t.actual_minutes = 250; assert_eq!(t.time_progress(), Some(100)); // zero estimate is treated as 0%, never divides by zero t.estimated_minutes = Some(0); assert_eq!(t.time_progress(), Some(0)); } #[test] fn is_over_estimate_rules() { let mut t = task(); assert!(!t.is_over_estimate(), "no estimate -> not over"); t.estimated_minutes = Some(60); t.actual_minutes = 61; assert!(t.is_over_estimate()); t.actual_minutes = 60; assert!(!t.is_over_estimate(), "equal is not over"); t.estimated_minutes = Some(0); t.actual_minutes = 5; assert!(!t.is_over_estimate(), "zero estimate is never over"); } #[test] fn has_active_timer_reads_session() { assert!(!task().has_active_timer()); } // NewTaskBuilder #[test] #[allow( clippy::float_cmp, reason = "asserting the builder stored the exact, directly-set f64 default" )] fn builder_defaults() { let nt = NewTask::builder("Write tests").build(); assert_eq!(nt.title, "Write tests"); assert_eq!(nt.description, ""); assert_eq!(nt.priority, Priority::Medium); assert_eq!(nt.urgency, 0.0); assert_eq!(nt.recurrence, Recurrence::None); assert!(nt.tags.is_empty()); assert!(nt.due.is_none()); assert!(nt.estimated_minutes.is_none()); } #[test] #[allow( clippy::float_cmp, reason = "asserting the builder stored the exact, directly-set f64 value" )] fn builder_sets_fields() { let due = Utc::now(); let nt = NewTask::builder("Fix bug") .priority(Priority::High) .due(due) .tag("urgent") .tag("backend") .urgency(8.0) .estimated_minutes(45) .recurrence(Recurrence::Daily) .build(); assert_eq!(nt.priority, Priority::High); assert_eq!(nt.due, Some(due)); assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]); assert_eq!(nt.urgency, 8.0); assert_eq!(nt.estimated_minutes, Some(45)); assert_eq!(nt.recurrence, Recurrence::Daily); } #[test] fn builder_tags_replaces_accumulated() { let nt = NewTask::builder("t") .tag("a") .tags(vec!["x".to_string(), "y".to_string()]) .build(); assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]); } // ── split_description ── #[test] fn a_short_single_line_is_all_title() { let (title, body) = split_description("Fix the bug"); assert_eq!(title, "Fix the bug"); assert_eq!(body, "", "a one-line task has no detail to show"); } #[test] fn the_first_line_becomes_the_title_and_the_rest_the_body() { let (title, body) = split_description("Ship the release\n\nTag it, then push tags."); assert_eq!(title, "Ship the release"); assert_eq!(body, "Tag it, then push tags."); } #[test] fn a_long_first_line_truncates_on_a_word_boundary() { let first = "Walk the human_todo sign-off table on the live server and record every row that still fails"; let (title, body) = split_description(first); assert!(title.chars().count() <= MAX_TITLE_LEN); assert!(title.ends_with("...")); assert!( !title.trim_end_matches("...").ends_with(char::is_whitespace), "no dangling space before the ellipsis: {title:?}" ); assert!( first.starts_with(title.trim_end_matches("...")), "the title is a prefix of the text it summarizes" ); assert_eq!(body, first, "truncating must not lose the tail"); } #[test] fn a_single_unbreakable_word_still_yields_a_title() { let word = "x".repeat(MAX_TITLE_LEN * 2); let (title, body) = split_description(&word); assert_eq!(title.chars().count(), MAX_TITLE_LEN); assert!(title.ends_with("...")); assert_eq!(body, word); } #[test] fn a_title_of_exactly_the_limit_is_not_truncated() { let exact = "y".repeat(MAX_TITLE_LEN); let (title, body) = split_description(&exact); assert_eq!(title, exact); assert_eq!(body, ""); } #[test] fn surrounding_whitespace_is_not_carried_into_either_field() { let (title, body) = split_description("\n Trim me \n\n body text\n\n"); assert_eq!(title, "Trim me"); assert_eq!(body, "body text"); }