Skip to main content

max / goingson

Move the task model and recurrence test modules to sibling files Both carried a trailing inline test module. recurrence.rs was 1458 lines holding 538 of production; task.rs 1528 holding 1110. Each test block becomes a tests.rs sibling behind a `#[cfg(test)] mod tests;` declaration. No production line changes. The production files keep their #[cfg(test)] attribute, so they stay in witchbroom's mutation scope.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-03 22:51 UTC
Signed with PGP, not checked
Commit: e4eac68aa6a2c973423cf55b8c0eaa96f66fbd39
Parent: c37794f
4 files changed, +920 insertions, -916 deletions
@@ -535,924 +535,4 @@
535 535 }
536 536
537 537 #[cfg(test)]
538 - mod tests {
539 - use super::*;
540 -
541 - /// A pending task with a due date and a rule, off the shared fixture so a
542 - /// new `Task` field does not need a second edit here.
543 - fn recurring_task(due: DateTime<Utc>, recurrence: Recurrence) -> crate::models::Task {
544 - let mut t = crate::models::test_task();
545 - t.due = Some(due);
546 - t.recurrence = recurrence;
547 - t
548 - }
549 -
550 - #[test]
551 - fn next_recurring_task_advances_the_due_date_and_chains_to_the_root() {
552 - let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
553 - let mut task = recurring_task(now, Recurrence::Weekly);
554 - task.scheduled_start = Some(now);
555 - task.scheduled_duration = Some(30);
556 -
557 - let next = next_recurring_task(&task, Tz::UTC, now).expect("weekly task recurs");
558 - assert_eq!(next.due.unwrap().day(), 10);
559 - // Chained to the root, and the time block from the closed occurrence is
560 - // not carried onto a date nobody picked.
561 - assert_eq!(next.recurrence_parent_id, Some(task.id));
562 - assert_eq!(next.scheduled_start, None);
563 - assert_eq!(next.scheduled_duration, None);
564 -
565 - // An instance chains to the root it came from, not to its predecessor.
566 - let mut instance = task.clone();
567 - instance.id = crate::id_types::TaskId::new();
568 - instance.recurrence_parent_id = Some(task.id);
569 - let third = next_recurring_task(&instance, Tz::UTC, now).unwrap();
570 - assert_eq!(third.recurrence_parent_id, Some(task.id));
571 - }
572 -
573 - #[test]
574 - fn next_recurring_task_without_a_due_anchors_to_now() {
575 - // A floating chore: nothing to advance from, so the chain starts at the
576 - // moment it was completed rather than getting no due date at all.
577 - let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
578 - let mut task = recurring_task(now, Recurrence::Daily);
579 - task.due = None;
580 -
581 - let next = next_recurring_task(&task, Tz::UTC, now).expect("still recurs");
582 - let due = next.due.expect("anchored to now, not left empty");
583 - assert!(
584 - due > now,
585 - "successor is due after the completion, got {due}"
586 - );
587 - }
588 -
589 - #[test]
590 - fn next_recurring_task_is_none_for_a_one_off() {
591 - let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
592 - let task = recurring_task(now, Recurrence::None);
593 - assert!(next_recurring_task(&task, Tz::UTC, now).is_none());
594 - }
595 -
596 - #[test]
597 - fn test_daily_recurrence() {
598 - let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
599 - let next = calculate_next_due(Some(&now), &Recurrence::Daily).unwrap();
600 - assert_eq!(next.day(), 5);
601 - }
602 -
603 - #[test]
604 - fn test_weekly_recurrence() {
605 - let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
606 - let next = calculate_next_due(Some(&now), &Recurrence::Weekly).unwrap();
607 - assert_eq!(next.day(), 11);
608 - }
609 -
610 - #[test]
611 - fn test_monthly_recurrence() {
612 - let now = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
613 - let next = calculate_next_due(Some(&now), &Recurrence::Monthly).unwrap();
614 - assert_eq!(next.month(), 2);
615 - assert_eq!(next.day(), 15);
616 - }
617 -
618 - #[test]
619 - fn test_monthly_end_of_month() {
620 - // Jan 31 -> Feb 28 (or 29 in leap year)
621 - let jan_31 = Utc.with_ymd_and_hms(2026, 1, 31, 10, 0, 0).unwrap();
622 - let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
623 - assert_eq!(next.month(), 2);
624 - // 2026 is not a leap year, so Feb has 28 days
625 - assert_eq!(next.day(), 28);
626 - }
627 -
628 - /// Fold `hops` completions, returning every due date in order.
629 - ///
630 - /// Recurrence is a chain: each completion re-derives from the previous
631 - /// instance's due date. Asserting a single hop from a fixed anchor passes even
632 - /// when the heuristic is wrong on the next one, which is exactly how the
633 - /// end-of-month drift survived a green suite.
634 - fn walk(start: DateTime<Utc>, recurrence: &Recurrence, hops: usize) -> Vec<(u32, u32)> {
635 - let mut out = Vec::new();
636 - let mut cur = start;
637 - for _ in 0..hops {
638 - cur = calculate_next_due(Some(&cur), recurrence).unwrap();
639 - out.push((cur.month(), cur.day()));
640 - }
641 - out
642 - }
643 -
644 - #[test]
645 - fn monthly_from_a_leap_february_stays_at_month_end() {
646 - // Feb 29 already snapped before the fix (29 was in range); pin it so the
647 - // two adjacent inputs cannot diverge again.
648 - let feb_29 = Utc.with_ymd_and_hms(2024, 2, 29, 10, 0, 0).unwrap();
649 - assert_eq!(
650 - walk(feb_29, &Recurrence::Monthly, 3),
651 - vec![(3, 31), (4, 30), (5, 31)]
652 - );
653 - }
654 -
655 - #[test]
656 - fn monthly_from_a_mid_month_day_does_not_drift_to_month_end() {
657 - // The guard must stay a month-end heuristic: day 15 is unambiguous and
658 - // must keep its day across the chain, including through February.
659 - let jan_15 = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
660 - assert_eq!(
661 - walk(jan_15, &Recurrence::Monthly, 3),
662 - vec![(2, 15), (3, 15), (4, 15)]
663 - );
664 - }
665 -
666 - #[test]
667 - fn monthly_from_a_non_leap_february_28_keeps_the_28th() {
668 - // Feb 28 in a non-leap year is ambiguous — the user may have meant "the
669 - // 28th" — so it must not be promoted to month-end intent.
670 - let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
671 - assert_eq!(
672 - walk(feb_28, &Recurrence::Monthly, 2),
673 - vec![(3, 28), (4, 28)]
674 - );
675 - }
676 -
677 - #[test]
678 - fn test_no_recurrence() {
679 - let now = Utc::now();
680 - let next = calculate_next_due(Some(&now), &Recurrence::None);
681 - assert!(next.is_none());
682 - }
683 -
684 - #[test]
685 - fn test_should_recur() {
686 - assert!(should_recur(&Recurrence::Daily));
687 - assert!(should_recur(&Recurrence::Weekly));
688 - assert!(should_recur(&Recurrence::Monthly));
689 - assert!(!should_recur(&Recurrence::None));
690 - }
691 -
692 - #[test]
693 - fn test_monthly_recurrence_preserves_time() {
694 - let original = Utc.with_ymd_and_hms(2026, 1, 15, 14, 30, 0).unwrap();
695 - let next = calculate_next_due(Some(&original), &Recurrence::Monthly).unwrap();
696 - assert_eq!(next.hour(), 14);
697 - assert_eq!(next.minute(), 30);
698 - }
699 -
700 - #[test]
701 - fn test_daily_recurrence_preserves_time() {
702 - let original = Utc.with_ymd_and_hms(2026, 2, 14, 9, 15, 30).unwrap();
703 - let next = calculate_next_due(Some(&original), &Recurrence::Daily).unwrap();
704 - assert_eq!(next.hour(), 9);
705 - assert_eq!(next.minute(), 15);
706 - assert_eq!(next.second(), 30);
707 - }
708 -
709 - #[test]
710 - fn test_weekly_recurrence_preserves_time() {
711 - let original = Utc.with_ymd_and_hms(2026, 3, 10, 17, 0, 0).unwrap();
712 - let next = calculate_next_due(Some(&original), &Recurrence::Weekly).unwrap();
713 - assert_eq!(next.hour(), 17);
714 - assert_eq!(next.minute(), 0);
715 - }
716 -
717 - #[test]
718 - fn test_monthly_december_to_january() {
719 - // Dec 15, 2026 -> Jan 15, 2027
720 - let dec_15 = Utc.with_ymd_and_hms(2026, 12, 15, 10, 0, 0).unwrap();
721 - let next = calculate_next_due(Some(&dec_15), &Recurrence::Monthly).unwrap();
722 - assert_eq!(next.year(), 2027);
723 - assert_eq!(next.month(), 1);
724 - assert_eq!(next.day(), 15);
725 - }
726 -
727 - #[test]
728 - fn test_monthly_leap_year() {
729 - // Jan 31, 2028 (leap year) -> Feb 29, 2028
730 - let jan_31 = Utc.with_ymd_and_hms(2028, 1, 31, 10, 0, 0).unwrap();
731 - let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
732 - assert_eq!(next.month(), 2);
733 - assert_eq!(next.day(), 29); // 2028 is a leap year
734 - }
735 -
736 - #[test]
737 - fn test_monthly_feb_28_no_snap() {
738 - // Feb 28 in a non-leap year: day < 29, so no end-of-month snap.
739 - // User who chose the 28th gets Mar 28, not Mar 31.
740 - let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
741 - let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap();
742 - assert_eq!(next.month(), 3);
743 - assert_eq!(next.day(), 28);
744 - }
745 -
746 - #[test]
747 - fn test_monthly_feb_28_explicit_day_28() {
748 - // With explicit target day 28, Feb 28 -> Mar 28 (no end-of-month heuristic)
749 - let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
750 - let next =
751 - calculate_next_due_with_day(Some(&feb_28), &Recurrence::Monthly, Some(28)).unwrap();
752 - assert_eq!(next.month(), 3);
753 - assert_eq!(next.day(), 28);
754 - }
755 -
756 - #[test]
757 - fn test_monthly_march_31_to_april() {
758 - // Mar 31 -> Apr 30 (April only has 30 days)
759 - let mar_31 = Utc.with_ymd_and_hms(2026, 3, 31, 10, 0, 0).unwrap();
760 - let next = calculate_next_due(Some(&mar_31), &Recurrence::Monthly).unwrap();
761 - assert_eq!(next.month(), 4);
762 - assert_eq!(next.day(), 30);
763 - }
764 -
765 - #[test]
766 - fn test_daily_year_boundary() {
767 - // Dec 31, 2026 -> Jan 1, 2027
768 - let dec_31 = Utc.with_ymd_and_hms(2026, 12, 31, 10, 0, 0).unwrap();
769 - let next = calculate_next_due(Some(&dec_31), &Recurrence::Daily).unwrap();
770 - assert_eq!(next.year(), 2027);
771 - assert_eq!(next.month(), 1);
772 - assert_eq!(next.day(), 1);
773 - }
774 -
775 - #[test]
776 - fn test_weekly_month_boundary() {
777 - // Jan 28, 2026 -> Feb 4, 2026
778 - let jan_28 = Utc.with_ymd_and_hms(2026, 1, 28, 10, 0, 0).unwrap();
779 - let next = calculate_next_due(Some(&jan_28), &Recurrence::Weekly).unwrap();
780 - assert_eq!(next.month(), 2);
781 - assert_eq!(next.day(), 4);
782 - }
783 -
784 - #[test]
785 - fn test_recurrence_with_no_due_date() {
786 - // When no due date provided, should use current time as base
787 - let next = calculate_next_due(None, &Recurrence::Daily);
788 - assert!(next.is_some());
789 -
790 - let next_date = next.unwrap();
791 - let now = Utc::now();
792 - // Next due should be approximately 1 day from now
793 - let diff = next_date - now;
794 - assert!(diff.num_hours() >= 23 && diff.num_hours() <= 25);
795 - }
796 -
797 - #[test]
798 - fn test_days_in_month_helper() {
799 - assert_eq!(days_in_month(2026, 1), 31); // January
800 - assert_eq!(days_in_month(2026, 2), 28); // February (non-leap)
801 - assert_eq!(days_in_month(2028, 2), 29); // February (leap year)
802 - assert_eq!(days_in_month(2026, 4), 30); // April
803 - assert_eq!(days_in_month(2026, 12), 31); // December
804 - }
805 -
806 - #[test]
807 - fn test_recurring_task_fresh_urgency_after_completion() {
808 - use crate::models::{Priority, TaskStatus};
809 - use crate::urgency::calculate_urgency;
810 -
811 - // Simulate an overdue recurring weekly task:
812 - // Original due date was 3 days ago, so it had high urgency from the overdue penalty.
813 - let overdue_due = Utc::now() - Duration::days(3);
814 - let old_created = Utc::now() - Duration::days(10);
815 - let tags: Vec<String> = vec![];
816 -
817 - let old_urgency = calculate_urgency(
818 - &Priority::Medium,
819 - &TaskStatus::Pending,
820 - Some(&overdue_due),
821 - &old_created,
822 - &tags,
823 - );
824 -
825 - // Old task should have overdue urgency (12.0 from overdue + priority + age)
826 - assert!(
827 - old_urgency > 15.0,
828 - "Overdue task should have high urgency, got: {old_urgency}"
829 - );
830 -
831 - // When completing and creating the next instance, we calculate next_due
832 - let next_due = calculate_next_due(Some(&overdue_due), &Recurrence::Weekly).unwrap();
833 - let new_created = Utc::now();
834 -
835 - let new_urgency = calculate_urgency(
836 - &Priority::Medium,
837 - &TaskStatus::Pending,
838 - Some(&next_due),
839 - &new_created,
840 - &tags,
841 - );
842 -
843 - // The new instance should NOT be overdue (due date is in the future)
844 - // and should have much lower urgency than the old overdue one
845 - assert!(
846 - new_urgency < old_urgency,
847 - "New recurring instance should have lower urgency ({new_urgency}) than the completed overdue one ({old_urgency})"
848 - );
849 -
850 - // Specifically, it should NOT have the overdue penalty
851 - assert!(
852 - new_urgency < 12.0,
853 - "New recurring instance should not have overdue penalty, got urgency: {new_urgency}"
854 - );
855 - }
856 -
857 - // Rich Recurrence Tests
858 -
859 - #[test]
860 - fn test_rich_daily_interval() {
861 - let now = Utc.with_ymd_and_hms(2026, 3, 1, 9, 0, 0).unwrap();
862 - let rule = RecurrenceRule {
863 - pattern: Recurrence::Daily,
864 - interval: 3,
865 - weekdays: vec![],
866 - monthly_spec: None,
867 - until: None,
868 - };
869 - let next = calculate_next_due_rich(Some(&now), &rule).unwrap();
870 - assert_eq!(next.day(), 4); // 3 days later
871 - assert_eq!(next.hour(), 9);
872 - }
873 -
874 - #[test]
875 - fn test_rich_weekly_weekdays() {
876 - // Monday, requesting Mon/Wed/Fri
877 - let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
878 - let rule = RecurrenceRule {
879 - pattern: Recurrence::Weekly,
880 - interval: 1,
881 - weekdays: vec![0, 2, 4], // Mon, Wed, Fri
882 - monthly_spec: None,
883 - until: None,
884 - };
885 - // Next after Monday should be Wednesday
886 - let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
887 - assert_eq!(next.weekday(), chrono::Weekday::Wed);
888 - assert_eq!(next.day(), 4);
889 -
890 - // Next after Wednesday should be Friday
891 - let next2 = calculate_next_due_rich(Some(&next), &rule).unwrap();
892 - assert_eq!(next2.weekday(), chrono::Weekday::Fri);
893 - assert_eq!(next2.day(), 6);
894 -
895 - // Next after Friday should be Monday of next week
896 - let next3 = calculate_next_due_rich(Some(&next2), &rule).unwrap();
897 - assert_eq!(next3.weekday(), chrono::Weekday::Mon);
898 - assert_eq!(next3.day(), 9);
899 - }
900 -
901 - #[test]
902 - fn test_rich_weekly_ignores_out_of_range_weekdays() {
903 - // A corrupt/imported weekday byte (200) must not drive a ~200-day jump;
904 - // out-of-range values are dropped, leaving the valid weekday (Wed).
905 - let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
906 - let rule = RecurrenceRule {
907 - pattern: Recurrence::Weekly,
908 - interval: 1,
909 - weekdays: vec![200, 2], // garbage + Wed
910 - monthly_spec: None,
911 - until: None,
912 - };
913 - let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
914 - assert_eq!(next.weekday(), chrono::Weekday::Wed);
915 - assert!(
916 - (next - mon).num_days() < 7,
917 - "must not jump far past one week"
918 - );
919 - }
920 -
921 - #[test]
922 - fn test_rich_weekly_all_invalid_weekdays_falls_back() {
923 - // If every weekday byte is invalid, advance by the interval-week instead
924 - // of panicking on an empty sorted list.
925 - let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
926 - let rule = RecurrenceRule {
927 - pattern: Recurrence::Weekly,
928 - interval: 1,
929 - weekdays: vec![99, 200],
930 - monthly_spec: None,
931 - until: None,
932 - };
933 - let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
934 - assert_eq!((next - mon).num_days(), 7);
935 - }
936 -
937 - #[test]
938 - fn test_rich_interval_clamped() {
939 - // An absurd interval must not overflow the i32 month cast or civil math.
940 - let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
941 - let rule = RecurrenceRule {
942 - pattern: Recurrence::Daily,
943 - interval: u32::MAX,
944 - weekdays: vec![],
945 - monthly_spec: None,
946 - until: None,
947 - };
948 - // Clamped to 10_000 days; just assert it produces a finite future date.
949 - let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
950 - assert!(next > mon);
951 - }
952 -
953 - #[test]
954 - fn test_rich_weekly_interval_2() {
955 - // Friday, every 2 weeks on Mon/Fri
956 - let fri = Utc.with_ymd_and_hms(2026, 3, 6, 10, 0, 0).unwrap(); // Friday
957 - let rule = RecurrenceRule {
958 - pattern: Recurrence::Weekly,
959 - interval: 2,
960 - weekdays: vec![0, 4], // Mon, Fri
961 - monthly_spec: None,
962 - until: None,
963 - };
964 - // Next after Friday: wrap to Mon of 2-weeks-later
965 - let next = calculate_next_due_rich(Some(&fri), &rule).unwrap();
966 - assert_eq!(next.weekday(), chrono::Weekday::Mon);
967 - assert_eq!(next.day(), 16); // 2 weeks later, Monday
968 - }
969 -
970 - #[test]
971 - fn test_rich_monthly_day_of_month() {
972 - let jan = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
973 - let rule = RecurrenceRule {
974 - pattern: Recurrence::Monthly,
975 - interval: 1,
976 - weekdays: vec![],
977 - monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }),
978 - until: None,
979 - };
980 - let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
981 - assert_eq!(next.month(), 2);
982 - assert_eq!(next.day(), 15);
983 - }
984 -
985 - #[test]
986 - fn test_rich_monthly_nth_weekday() {
987 - // 2nd Friday of January 2026 is Jan 9... let me compute
988 - // Jan 2026: 1=Thu, 2=Fri (1st Fri), 9=Fri (2nd Fri)
989 - let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap();
990 - let rule = RecurrenceRule {
991 - pattern: Recurrence::Monthly,
992 - interval: 1,
993 - weekdays: vec![],
994 - monthly_spec: Some(MonthlySpec::NthWeekday {
995 - week: 2,
996 - weekday: 4,
997 - }), // 2nd Friday
998 - until: None,
999 - };
1000 - let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
1001 - // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri)
1002 - assert_eq!(next.month(), 2);
1003 - assert_eq!(next.day(), 13);
1004 - }
1005 -
1006 - #[test]
1007 - fn test_rich_monthly_last_weekday() {
1008 - let jan = Utc.with_ymd_and_hms(2026, 1, 26, 10, 0, 0).unwrap();
1009 - let rule = RecurrenceRule {
1010 - pattern: Recurrence::Monthly,
1011 - interval: 1,
1012 - weekdays: vec![],
1013 - monthly_spec: Some(MonthlySpec::NthWeekday {
1014 - week: -1,
1015 - weekday: 0,
1016 - }), // Last Monday
1017 - until: None,
1018 - };
1019 - let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
1020 - // Feb 2026: last Monday is Feb 23
1021 - assert_eq!(next.month(), 2);
1022 - assert_eq!(next.day(), 23);
1023 - }
1024 -
1025 - #[test]
1026 - fn test_rich_monthly_nth_weekday_rejects_out_of_range_week() {
1027 - let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap();
1028 - for week in [0i8, 6, 7, -2, i8::MIN, i8::MAX] {
1029 - let rule = RecurrenceRule {
1030 - pattern: Recurrence::Monthly,
1031 - interval: 1,
1032 - weekdays: vec![],
1033 - monthly_spec: Some(MonthlySpec::NthWeekday { week, weekday: 4 }),
1034 - until: None,
Lines truncated
@@ -1047,425 +1047,7 @@
1047 1047 }
1048 1048
1049 1049 #[cfg(test)]
1050 - mod tests {
1051 - use super::*;
1052 - use crate::id_types::{SubtaskId, TaskId};
1053 - use crate::models::shared::{CssClass, DbValue, Recurrence};
1054 - use chrono::{Duration, Utc};
1055 - use std::str::FromStr;
1056 -
1057 - use super::test_task as task;
1058 -
1059 - fn subtask(is_completed: bool) -> Subtask {
1060 - Subtask {
1061 - id: SubtaskId::new(),
1062 - task_id: TaskId::new(),
1063 - text: "sub".to_string(),
1064 - linked_task_id: None,
1065 - is_completed,
1066 - position: 0,
1067 - }
1068 - }
1069 -
1070 - // TaskStatus
1071 -
1072 - #[test]
1073 - fn task_status_as_str_and_css_and_db() {
1074 - assert_eq!(TaskStatus::Started.as_str(), "Started");
1075 - assert_eq!(TaskStatus::Completed.css_class(), "task-completed");
1076 - assert_eq!(TaskStatus::Deleted.db_value(), "Deleted");
1077 - assert_eq!(TaskStatus::default(), TaskStatus::Pending);
1078 - }
1079 -
1080 - #[test]
1081 - fn task_status_from_str() {
1082 - assert_eq!(
1083 - TaskStatus::from_str("Completed").unwrap(),
1084 - TaskStatus::Completed
1085 - );
1086 - assert!(TaskStatus::from_str("nonsense").is_err());
1087 - }
1088 -
1089 - // Priority
1090 -
1091 - #[test]
1092 - fn priority_as_str_is_short_form() {
1093 - assert_eq!(Priority::High.as_str(), "H");
1094 - assert_eq!(Priority::Medium.as_str(), "M");
1095 - assert_eq!(Priority::Low.as_str(), "L");
1096 - }
1097 -
1098 - #[test]
1099 - fn priority_from_str_or_default_accepts_variants() {
1100 - for s in ["High", "H", "high", "h"] {
1101 - assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}");
1102 - }
1103 - for s in ["Low", "L", "low", "l"] {
1104 - assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}");
1105 - }
1106 - for s in ["Medium", "M", "Med", "med", "m"] {
1107 - assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}");
1108 - }
1109 - }
1110 -
1111 - #[test]
1112 - fn priority_from_str_or_default_falls_back_to_medium() {
1113 - assert_eq!(Priority::from_str_or_default(""), Priority::Medium);
1114 - assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium);
1115 - assert_eq!(Priority::default(), Priority::Medium);
1116 - }
1117 -
1118 - #[test]
1119 - fn priority_db_value_is_long_form() {
1120 - assert_eq!(Priority::High.db_value(), "High");
1121 - assert_eq!(Priority::Low.css_class(), "priority-low");
1122 - }
1123 -
1124 - // TaskSortColumn
1125 -
1126 - #[test]
1127 - fn sort_column_parses_case_insensitively() {
1128 - assert_eq!(
1129 - TaskSortColumn::from_str_or_default("DUE"),
1130 - TaskSortColumn::Due
1131 - );
1132 - assert_eq!(
1133 - TaskSortColumn::from_str_or_default("Project"),
1134 - TaskSortColumn::Project
1135 - );
1136 - assert_eq!(
1137 - TaskSortColumn::from_str_or_default("priority"),
1138 - TaskSortColumn::Priority
1139 - );
1140 - // unknown falls back to the default (Urgency)
1141 - assert_eq!(
1142 - TaskSortColumn::from_str_or_default("xyz"),
1143 - TaskSortColumn::Urgency
1144 - );
1145 - assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency);
1146 - }
1147 -
1148 - // due_formatted
1149 -
1150 - #[test]
1151 - fn due_formatted_none_is_dash() {
1152 - assert_eq!(task().due_formatted(), "-");
1153 - }
1154 -
1155 - #[test]
1156 - fn due_formatted_relative_buckets() {
1157 - let mut t = task();
1158 -
1159 - t.due = Some(Utc::now());
1160 - assert_eq!(t.due_formatted(), "today");
1161 -
1162 - t.due = Some(Utc::now() + Duration::days(1));
1163 - assert_eq!(t.due_formatted(), "tomorrow");
1164 -
1165 - t.due = Some(Utc::now() + Duration::days(3));
1166 - assert_eq!(t.due_formatted(), "+3d");
1167 -
1168 - t.due = Some(Utc::now() - Duration::days(2));
1169 - assert_eq!(t.due_formatted(), "2d ago");
1170 - }
1171 -
1172 - #[test]
1173 - fn due_formatted_far_future_is_iso_date() {
1174 - let mut t = task();
1175 - let far = Utc::now() + Duration::days(30);
1176 - t.due = Some(far);
1177 - assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string());
1178 - }
1179 -
1180 - // overdue / urgency_class
1181 -
1182 - #[test]
1183 - fn is_overdue_reads_due_vs_now() {
1184 - let mut t = task();
1185 - assert!(!t.is_overdue(), "no due date is never overdue");
1186 - t.due = Some(Utc::now() - Duration::hours(1));
1187 - assert!(t.is_overdue());
1188 - t.due = Some(Utc::now() + Duration::hours(1));
1189 - assert!(!t.is_overdue());
1190 - }
1191 -
1192 - #[test]
1193 - fn urgency_class_thresholds() {
1194 - let mut t = task();
1195 - t.urgency = 9.0;
1196 - assert_eq!(t.urgency_class(), "urgency-high");
1197 - t.urgency = 5.0;
1198 - assert_eq!(t.urgency_class(), "urgency-medium");
1199 - t.urgency = 4.9;
1200 - assert_eq!(t.urgency_class(), "urgency-low");
1201 - }
1202 -
1203 - #[test]
1204 - fn urgency_class_overdue_wins_over_score() {
1205 - let mut t = task();
1206 - t.urgency = 9.9; // would be "high"
1207 - t.due = Some(Utc::now() - Duration::days(1));
1208 - assert_eq!(t.urgency_class(), "urgency-overdue");
1209 - }
1210 -
1211 - #[test]
1212 - fn urgency_formatted_one_decimal() {
1213 - let mut t = task();
1214 - t.urgency = 8.34;
1215 - assert_eq!(t.urgency_formatted(), "8.3");
1216 - t.urgency = 0.0;
1217 - assert_eq!(t.urgency_formatted(), "0.0");
1218 - }
1219 -
1220 - #[test]
1221 - fn due_timestamp_defaults_to_zero() {
1222 - let mut t = task();
1223 - assert_eq!(t.due_timestamp(), 0);
1224 - let d = Utc::now();
1225 - t.due = Some(d);
1226 - assert_eq!(t.due_timestamp(), d.timestamp());
1227 - }
1228 -
1229 - // subtasks / annotations
1230 -
1231 - #[test]
1232 - fn subtask_counts_and_progress() {
1233 - let mut t = task();
1234 - assert!(!t.has_subtasks());
1235 - assert_eq!(t.subtasks_progress(), "0/0");
1236 - t.subtasks = vec![subtask(true), subtask(false), subtask(true)];
1237 - assert!(t.has_subtasks());
1238 - assert_eq!(t.subtask_count(), 3);
1239 - assert_eq!(t.subtasks_completed(), 2);
1240 - assert_eq!(t.subtasks_progress(), "2/3");
1241 - }
1242 -
1243 - #[test]
1244 - fn project_name_fallbacks() {
1245 - let mut t = task();
1246 - assert_eq!(t.project_name_or_dash(), "-");
1247 - assert_eq!(t.project_name_or_empty(), "");
1248 - t.project_name = Some("Website".to_string());
1249 - assert_eq!(t.project_name_or_dash(), "Website");
1250 - assert_eq!(t.project_name_or_empty(), "Website");
1251 - }
1252 -
1253 - fn token(t: &Task, reference: &str, state: TokenState, primary: bool, pos: i32) -> StatusToken {
1254 - StatusToken {
1255 - id: StatusToken::deterministic_id(t.id, TOKEN_KIND_COMMIT, reference),
1256 - task_id: t.id,
1257 - kind: TOKEN_KIND_COMMIT.to_string(),
1258 - reference: reference.to_string(),
1259 - state,
1260 - is_primary: primary,
1261 - position: pos,
1262 - }
1263 - }
1264 -
1265 - #[test]
1266 - fn status_token_deterministic_id_is_stable_and_content_derived() {
1267 - let tid = TaskId::new();
1268 - let a = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
1269 - let b = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
1270 - assert_eq!(a, b, "same task+kind+ref must yield the same id");
1271 - assert_ne!(
1272 - a,
1273 - StatusToken::deterministic_id(tid, "commit", "deox@a19f0011"),
1274 - "ref differs"
1275 - );
1276 - assert_ne!(
1277 - a,
1278 - StatusToken::deterministic_id(tid, "attachment", "deox@7c236fca8"),
1279 - "kind differs"
1280 - );
1281 - assert_ne!(
1282 - a,
1283 - StatusToken::deterministic_id(TaskId::new(), "commit", "deox@7c236fca8"),
1284 - "task differs"
1285 - );
1286 - assert_eq!(a.as_uuid().get_version_num(), 5);
1287 - }
1288 -
1289 - #[test]
1290 - fn primary_token_finds_the_flagged_one() {
1291 - let mut t = task();
1292 - assert!(!t.has_status_tokens());
1293 - assert!(t.primary_token().is_none());
1294 - t.status_tokens = vec![
1295 - token(&t, "deox@aaa", TokenState::Pending, false, 0),
1296 - token(&t, "deox@bbb", TokenState::Complete, true, 1),
1297 - ];
1298 - assert!(t.has_status_tokens());
1299 - assert_eq!(
1300 - t.primary_token().map(|c| c.reference.as_str()),
1301 - Some("deox@bbb")
1302 - );
1303 - }
1304 -
1305 - #[test]
1306 - fn status_token_summary_rolls_up_states() {
1307 - let mut t = task();
1308 - assert_eq!(t.status_token_summary(), "neutral");
1309 - t.status_tokens = vec![token(&t, "deox@aaa", TokenState::Pending, false, 0)];
1310 - assert_eq!(t.status_token_summary(), "pending");
1311 - t.status_tokens = vec![
1312 - token(&t, "deox@aaa", TokenState::Complete, false, 0),
1313 - token(&t, "deox@bbb", TokenState::Pending, true, 1),
1314 - ];
1315 - assert_eq!(
1316 - t.status_token_summary(),
1317 - "pending",
1318 - "any pending keeps the rollup pending"
1319 - );
1320 - t.status_tokens = vec![
1321 - token(&t, "deox@aaa", TokenState::Complete, false, 0),
1322 - token(&t, "deox@bbb", TokenState::Complete, true, 1),
1323 - ];
1324 - assert_eq!(
1325 - t.status_token_summary(),
1326 - "complete",
1327 - "all complete rolls up complete"
1328 - );
1329 - }
1330 -
1331 - #[test]
1332 - fn recurrence_and_source_flags() {
1333 - let mut t = task();
1334 - assert!(!t.has_recurrence());
1335 - assert!(t.effective_recurrence_rule().is_none());
1336 - t.recurrence = Recurrence::Weekly;
1337 - assert!(t.has_recurrence());
1338 - assert!(!t.has_source_email());
1339 - }
1340 -
1341 - // snooze / waiting / focus
1342 -
1343 - #[test]
1344 - fn is_snoozed_only_when_future() {
1345 - let mut t = task();
1346 - assert!(!t.is_snoozed());
1347 - t.snoozed_until = Some(Utc::now() + Duration::hours(1));
1348 - assert!(t.is_snoozed());
1349 - t.snoozed_until = Some(Utc::now() - Duration::hours(1));
1350 - assert!(!t.is_snoozed());
1351 - }
1352 -
1353 - #[test]
1354 - fn response_overdue_requires_waiting_and_past_date() {
1355 - let mut t = task();
1356 - assert!(!t.is_response_overdue());
1357 - // past expected date but not waiting -> false
1358 - t.expected_response_date = Some(Utc::now() - Duration::days(1));
1359 - assert!(!t.is_response_overdue());
1360 - // waiting + past -> true
1361 - t.waiting_for_response = true;
1362 - assert!(t.is_waiting());
1363 - assert!(t.is_response_overdue());
1364 - // waiting but future -> false
1365 - t.expected_response_date = Some(Utc::now() + Duration::days(1));
1366 - assert!(!t.is_response_overdue());
1367 - }
1368 -
1369 - #[test]
1370 - fn is_focused_reads_flag() {
1371 - let mut t = task();
1372 - assert!(!t.is_focused());
1373 - t.is_focus = true;
1374 - assert!(t.is_focused());
1375 - }
1376 -
1377 - // time progress
1378 -
1379 - #[test]
1380 - fn time_progress_none_without_estimate() {
1381 - assert_eq!(task().time_progress(), None);
1382 - }
1383 -
1384 - #[test]
1385 - fn time_progress_percentage_and_clamp() {
1386 - let mut t = task();
1387 - t.estimated_minutes = Some(100);
1388 - t.actual_minutes = 50;
1389 - assert_eq!(t.time_progress(), Some(50));
1390 - // clamps at 100 even when over
1391 - t.actual_minutes = 250;
1392 - assert_eq!(t.time_progress(), Some(100));
1393 - // zero estimate is treated as 0%, never divides by zero
1394 - t.estimated_minutes = Some(0);
1395 - assert_eq!(t.time_progress(), Some(0));
1396 - }
1397 -
1398 - #[test]
1399 - fn is_over_estimate_rules() {
1400 - let mut t = task();
1401 - assert!(!t.is_over_estimate(), "no estimate -> not over");
1402 - t.estimated_minutes = Some(60);
1403 - t.actual_minutes = 61;
1404 - assert!(t.is_over_estimate());
1405 - t.actual_minutes = 60;
1406 - assert!(!t.is_over_estimate(), "equal is not over");
1407 - t.estimated_minutes = Some(0);
1408 - t.actual_minutes = 5;
1409 - assert!(!t.is_over_estimate(), "zero estimate is never over");
1410 - }
1411 -
1412 - #[test]
1413 - fn has_active_timer_reads_session() {
1414 - assert!(!task().has_active_timer());
1415 - }
1416 -
1417 - // NewTaskBuilder
1418 -
1419 - #[test]
1420 - #[allow(
1421 - clippy::float_cmp,
1422 - reason = "asserting the builder stored the exact, directly-set f64 default"
1423 - )]
1424 - fn builder_defaults() {
1425 - let nt = NewTask::builder("Write tests").build();
1426 - assert_eq!(nt.title, "Write tests");
1427 - assert_eq!(nt.description, "");
1428 - assert_eq!(nt.priority, Priority::Medium);
1429 - assert_eq!(nt.urgency, 0.0);
1430 - assert_eq!(nt.recurrence, Recurrence::None);
1431 - assert!(nt.tags.is_empty());
1432 - assert!(nt.due.is_none());
1433 - assert!(nt.estimated_minutes.is_none());
1434 - }
1435 -
1436 - #[test]
1437 - #[allow(
1438 - clippy::float_cmp,
1439 - reason = "asserting the builder stored the exact, directly-set f64 value"
1440 - )]
1441 - fn builder_sets_fields() {
1442 - let due = Utc::now();
1443 - let nt = NewTask::builder("Fix bug")
1444 - .priority(Priority::High)
1445 - .due(due)
1446 - .tag("urgent")
1447 - .tag("backend")
1448 - .urgency(8.0)
1449 - .estimated_minutes(45)
1450 - .recurrence(Recurrence::Daily)
1451 - .build();
1452 - assert_eq!(nt.priority, Priority::High);
1453 - assert_eq!(nt.due, Some(due));
1454 - assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]);
1455 - assert_eq!(nt.urgency, 8.0);
1456 - assert_eq!(nt.estimated_minutes, Some(45));
1457 - assert_eq!(nt.recurrence, Recurrence::Daily);
1458 - }
1459 -
1460 - #[test]
1461 - fn builder_tags_replaces_accumulated() {
1462 - let nt = NewTask::builder("t")
1463 - .tag("a")
1464 - .tags(vec!["x".to_string(), "y".to_string()])
1465 - .build();
1466 - assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]);
1467 - }
1468 - }
1050 + mod tests;
1469 1051
1470 1052 #[cfg(test)]
1471 1053 mod split_description_tests {
@@ -1,0 +1,419 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use crate::id_types::{SubtaskId, TaskId};
5 + use crate::models::shared::{CssClass, DbValue, Recurrence};
6 + use chrono::{Duration, Utc};
7 + use std::str::FromStr;
8 +
9 + use super::test_task as task;
10 +
11 + fn subtask(is_completed: bool) -> Subtask {
12 + Subtask {
13 + id: SubtaskId::new(),
14 + task_id: TaskId::new(),
15 + text: "sub".to_string(),
16 + linked_task_id: None,
17 + is_completed,
18 + position: 0,
19 + }
20 + }
21 +
22 + // TaskStatus
23 +
24 + #[test]
25 + fn task_status_as_str_and_css_and_db() {
26 + assert_eq!(TaskStatus::Started.as_str(), "Started");
27 + assert_eq!(TaskStatus::Completed.css_class(), "task-completed");
28 + assert_eq!(TaskStatus::Deleted.db_value(), "Deleted");
29 + assert_eq!(TaskStatus::default(), TaskStatus::Pending);
30 + }
31 +
32 + #[test]
33 + fn task_status_from_str() {
34 + assert_eq!(
35 + TaskStatus::from_str("Completed").unwrap(),
36 + TaskStatus::Completed
37 + );
38 + assert!(TaskStatus::from_str("nonsense").is_err());
39 + }
40 +
41 + // Priority
42 +
43 + #[test]
44 + fn priority_as_str_is_short_form() {
45 + assert_eq!(Priority::High.as_str(), "H");
46 + assert_eq!(Priority::Medium.as_str(), "M");
47 + assert_eq!(Priority::Low.as_str(), "L");
48 + }
49 +
50 + #[test]
51 + fn priority_from_str_or_default_accepts_variants() {
52 + for s in ["High", "H", "high", "h"] {
53 + assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}");
54 + }
55 + for s in ["Low", "L", "low", "l"] {
56 + assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}");
57 + }
58 + for s in ["Medium", "M", "Med", "med", "m"] {
59 + assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}");
60 + }
61 + }
62 +
63 + #[test]
64 + fn priority_from_str_or_default_falls_back_to_medium() {
65 + assert_eq!(Priority::from_str_or_default(""), Priority::Medium);
66 + assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium);
67 + assert_eq!(Priority::default(), Priority::Medium);
68 + }
69 +
70 + #[test]
71 + fn priority_db_value_is_long_form() {
72 + assert_eq!(Priority::High.db_value(), "High");
73 + assert_eq!(Priority::Low.css_class(), "priority-low");
74 + }
75 +
76 + // TaskSortColumn
77 +
78 + #[test]
79 + fn sort_column_parses_case_insensitively() {
80 + assert_eq!(
81 + TaskSortColumn::from_str_or_default("DUE"),
82 + TaskSortColumn::Due
83 + );
84 + assert_eq!(
85 + TaskSortColumn::from_str_or_default("Project"),
86 + TaskSortColumn::Project
87 + );
88 + assert_eq!(
89 + TaskSortColumn::from_str_or_default("priority"),
90 + TaskSortColumn::Priority
91 + );
92 + // unknown falls back to the default (Urgency)
93 + assert_eq!(
94 + TaskSortColumn::from_str_or_default("xyz"),
95 + TaskSortColumn::Urgency
96 + );
97 + assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency);
98 + }
99 +
100 + // due_formatted
101 +
102 + #[test]
103 + fn due_formatted_none_is_dash() {
104 + assert_eq!(task().due_formatted(), "-");
105 + }
106 +
107 + #[test]
108 + fn due_formatted_relative_buckets() {
109 + let mut t = task();
110 +
111 + t.due = Some(Utc::now());
112 + assert_eq!(t.due_formatted(), "today");
113 +
114 + t.due = Some(Utc::now() + Duration::days(1));
115 + assert_eq!(t.due_formatted(), "tomorrow");
116 +
117 + t.due = Some(Utc::now() + Duration::days(3));
118 + assert_eq!(t.due_formatted(), "+3d");
119 +
120 + t.due = Some(Utc::now() - Duration::days(2));
121 + assert_eq!(t.due_formatted(), "2d ago");
122 + }
123 +
124 + #[test]
125 + fn due_formatted_far_future_is_iso_date() {
126 + let mut t = task();
127 + let far = Utc::now() + Duration::days(30);
128 + t.due = Some(far);
129 + assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string());
130 + }
131 +
132 + // overdue / urgency_class
133 +
134 + #[test]
135 + fn is_overdue_reads_due_vs_now() {
136 + let mut t = task();
137 + assert!(!t.is_overdue(), "no due date is never overdue");
138 + t.due = Some(Utc::now() - Duration::hours(1));
139 + assert!(t.is_overdue());
140 + t.due = Some(Utc::now() + Duration::hours(1));
141 + assert!(!t.is_overdue());
142 + }
143 +
144 + #[test]
145 + fn urgency_class_thresholds() {
146 + let mut t = task();
147 + t.urgency = 9.0;
148 + assert_eq!(t.urgency_class(), "urgency-high");
149 + t.urgency = 5.0;
150 + assert_eq!(t.urgency_class(), "urgency-medium");
151 + t.urgency = 4.9;
152 + assert_eq!(t.urgency_class(), "urgency-low");
153 + }
154 +
155 + #[test]
156 + fn urgency_class_overdue_wins_over_score() {
157 + let mut t = task();
158 + t.urgency = 9.9; // would be "high"
159 + t.due = Some(Utc::now() - Duration::days(1));
160 + assert_eq!(t.urgency_class(), "urgency-overdue");
161 + }
162 +
163 + #[test]
164 + fn urgency_formatted_one_decimal() {
165 + let mut t = task();
166 + t.urgency = 8.34;
167 + assert_eq!(t.urgency_formatted(), "8.3");
168 + t.urgency = 0.0;
169 + assert_eq!(t.urgency_formatted(), "0.0");
170 + }
171 +
172 + #[test]
173 + fn due_timestamp_defaults_to_zero() {
174 + let mut t = task();
175 + assert_eq!(t.due_timestamp(), 0);
176 + let d = Utc::now();
177 + t.due = Some(d);
178 + assert_eq!(t.due_timestamp(), d.timestamp());
179 + }
180 +
181 + // subtasks / annotations
182 +
183 + #[test]
184 + fn subtask_counts_and_progress() {
185 + let mut t = task();
186 + assert!(!t.has_subtasks());
187 + assert_eq!(t.subtasks_progress(), "0/0");
188 + t.subtasks = vec![subtask(true), subtask(false), subtask(true)];
189 + assert!(t.has_subtasks());
190 + assert_eq!(t.subtask_count(), 3);
191 + assert_eq!(t.subtasks_completed(), 2);
192 + assert_eq!(t.subtasks_progress(), "2/3");
193 + }
194 +
195 + #[test]
196 + fn project_name_fallbacks() {
197 + let mut t = task();
198 + assert_eq!(t.project_name_or_dash(), "-");
199 + assert_eq!(t.project_name_or_empty(), "");
200 + t.project_name = Some("Website".to_string());
201 + assert_eq!(t.project_name_or_dash(), "Website");
202 + assert_eq!(t.project_name_or_empty(), "Website");
203 + }
204 +
205 + fn token(t: &Task, reference: &str, state: TokenState, primary: bool, pos: i32) -> StatusToken {
206 + StatusToken {
207 + id: StatusToken::deterministic_id(t.id, TOKEN_KIND_COMMIT, reference),
208 + task_id: t.id,
209 + kind: TOKEN_KIND_COMMIT.to_string(),
210 + reference: reference.to_string(),
211 + state,
212 + is_primary: primary,
213 + position: pos,
214 + }
215 + }
216 +
217 + #[test]
218 + fn status_token_deterministic_id_is_stable_and_content_derived() {
219 + let tid = TaskId::new();
220 + let a = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
221 + let b = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
222 + assert_eq!(a, b, "same task+kind+ref must yield the same id");
223 + assert_ne!(
224 + a,
225 + StatusToken::deterministic_id(tid, "commit", "deox@a19f0011"),
226 + "ref differs"
227 + );
228 + assert_ne!(
229 + a,
230 + StatusToken::deterministic_id(tid, "attachment", "deox@7c236fca8"),
231 + "kind differs"
232 + );
233 + assert_ne!(
234 + a,
235 + StatusToken::deterministic_id(TaskId::new(), "commit", "deox@7c236fca8"),
236 + "task differs"
237 + );
238 + assert_eq!(a.as_uuid().get_version_num(), 5);
239 + }
240 +
241 + #[test]
242 + fn primary_token_finds_the_flagged_one() {
243 + let mut t = task();
244 + assert!(!t.has_status_tokens());
245 + assert!(t.primary_token().is_none());
246 + t.status_tokens = vec![
247 + token(&t, "deox@aaa", TokenState::Pending, false, 0),
248 + token(&t, "deox@bbb", TokenState::Complete, true, 1),
249 + ];
250 + assert!(t.has_status_tokens());
251 + assert_eq!(
252 + t.primary_token().map(|c| c.reference.as_str()),
253 + Some("deox@bbb")
254 + );
255 + }
256 +
257 + #[test]
258 + fn status_token_summary_rolls_up_states() {
259 + let mut t = task();
260 + assert_eq!(t.status_token_summary(), "neutral");
261 + t.status_tokens = vec![token(&t, "deox@aaa", TokenState::Pending, false, 0)];
262 + assert_eq!(t.status_token_summary(), "pending");
263 + t.status_tokens = vec![
264 + token(&t, "deox@aaa", TokenState::Complete, false, 0),
265 + token(&t, "deox@bbb", TokenState::Pending, true, 1),
266 + ];
267 + assert_eq!(
268 + t.status_token_summary(),
269 + "pending",
270 + "any pending keeps the rollup pending"
271 + );
272 + t.status_tokens = vec![
273 + token(&t, "deox@aaa", TokenState::Complete, false, 0),
274 + token(&t, "deox@bbb", TokenState::Complete, true, 1),
275 + ];
276 + assert_eq!(
277 + t.status_token_summary(),
278 + "complete",
279 + "all complete rolls up complete"
280 + );
281 + }
282 +
283 + #[test]
284 + fn recurrence_and_source_flags() {
285 + let mut t = task();
286 + assert!(!t.has_recurrence());
287 + assert!(t.effective_recurrence_rule().is_none());
288 + t.recurrence = Recurrence::Weekly;
289 + assert!(t.has_recurrence());
290 + assert!(!t.has_source_email());
291 + }
292 +
293 + // snooze / waiting / focus
294 +
295 + #[test]
296 + fn is_snoozed_only_when_future() {
297 + let mut t = task();
298 + assert!(!t.is_snoozed());
299 + t.snoozed_until = Some(Utc::now() + Duration::hours(1));
300 + assert!(t.is_snoozed());
301 + t.snoozed_until = Some(Utc::now() - Duration::hours(1));
302 + assert!(!t.is_snoozed());
303 + }
304 +
305 + #[test]
306 + fn response_overdue_requires_waiting_and_past_date() {
307 + let mut t = task();
308 + assert!(!t.is_response_overdue());
309 + // past expected date but not waiting -> false
310 + t.expected_response_date = Some(Utc::now() - Duration::days(1));
311 + assert!(!t.is_response_overdue());
312 + // waiting + past -> true
313 + t.waiting_for_response = true;
314 + assert!(t.is_waiting());
315 + assert!(t.is_response_overdue());
316 + // waiting but future -> false
317 + t.expected_response_date = Some(Utc::now() + Duration::days(1));
318 + assert!(!t.is_response_overdue());
319 + }
320 +
321 + #[test]
322 + fn is_focused_reads_flag() {
323 + let mut t = task();
324 + assert!(!t.is_focused());
325 + t.is_focus = true;
326 + assert!(t.is_focused());
327 + }
328 +
329 + // time progress
330 +
331 + #[test]
332 + fn time_progress_none_without_estimate() {
333 + assert_eq!(task().time_progress(), None);
334 + }
335 +
336 + #[test]
337 + fn time_progress_percentage_and_clamp() {
338 + let mut t = task();
339 + t.estimated_minutes = Some(100);
340 + t.actual_minutes = 50;
341 + assert_eq!(t.time_progress(), Some(50));
342 + // clamps at 100 even when over
343 + t.actual_minutes = 250;
344 + assert_eq!(t.time_progress(), Some(100));
345 + // zero estimate is treated as 0%, never divides by zero
346 + t.estimated_minutes = Some(0);
347 + assert_eq!(t.time_progress(), Some(0));
348 + }
349 +
350 + #[test]
351 + fn is_over_estimate_rules() {
352 + let mut t = task();
353 + assert!(!t.is_over_estimate(), "no estimate -> not over");
354 + t.estimated_minutes = Some(60);
355 + t.actual_minutes = 61;
356 + assert!(t.is_over_estimate());
357 + t.actual_minutes = 60;
358 + assert!(!t.is_over_estimate(), "equal is not over");
359 + t.estimated_minutes = Some(0);
360 + t.actual_minutes = 5;
361 + assert!(!t.is_over_estimate(), "zero estimate is never over");
362 + }
363 +
364 + #[test]
365 + fn has_active_timer_reads_session() {
366 + assert!(!task().has_active_timer());
367 + }
368 +
369 + // NewTaskBuilder
370 +
371 + #[test]
372 + #[allow(
373 + clippy::float_cmp,
374 + reason = "asserting the builder stored the exact, directly-set f64 default"
375 + )]
376 + fn builder_defaults() {
377 + let nt = NewTask::builder("Write tests").build();
378 + assert_eq!(nt.title, "Write tests");
379 + assert_eq!(nt.description, "");
380 + assert_eq!(nt.priority, Priority::Medium);
381 + assert_eq!(nt.urgency, 0.0);
382 + assert_eq!(nt.recurrence, Recurrence::None);
383 + assert!(nt.tags.is_empty());
384 + assert!(nt.due.is_none());
385 + assert!(nt.estimated_minutes.is_none());
386 + }
387 +
388 + #[test]
389 + #[allow(
390 + clippy::float_cmp,
391 + reason = "asserting the builder stored the exact, directly-set f64 value"
392 + )]
393 + fn builder_sets_fields() {
394 + let due = Utc::now();
395 + let nt = NewTask::builder("Fix bug")
396 + .priority(Priority::High)
397 + .due(due)
398 + .tag("urgent")
399 + .tag("backend")
400 + .urgency(8.0)
401 + .estimated_minutes(45)
402 + .recurrence(Recurrence::Daily)
403 + .build();
404 + assert_eq!(nt.priority, Priority::High);
405 + assert_eq!(nt.due, Some(due));
406 + assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]);
407 + assert_eq!(nt.urgency, 8.0);
408 + assert_eq!(nt.estimated_minutes, Some(45));
409 + assert_eq!(nt.recurrence, Recurrence::Daily);
410 + }
411 +
412 + #[test]
413 + fn builder_tags_replaces_accumulated() {
414 + let nt = NewTask::builder("t")
415 + .tag("a")
416 + .tags(vec!["x".to_string(), "y".to_string()])
417 + .build();
418 + assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]);
419 + }
@@ -1,0 +1,920 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + /// A pending task with a due date and a rule, off the shared fixture so a
6 + /// new `Task` field does not need a second edit here.
7 + fn recurring_task(due: DateTime<Utc>, recurrence: Recurrence) -> crate::models::Task {
8 + let mut t = crate::models::test_task();
9 + t.due = Some(due);
10 + t.recurrence = recurrence;
11 + t
12 + }
13 +
14 + #[test]
15 + fn next_recurring_task_advances_the_due_date_and_chains_to_the_root() {
16 + let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
17 + let mut task = recurring_task(now, Recurrence::Weekly);
18 + task.scheduled_start = Some(now);
19 + task.scheduled_duration = Some(30);
20 +
21 + let next = next_recurring_task(&task, Tz::UTC, now).expect("weekly task recurs");
22 + assert_eq!(next.due.unwrap().day(), 10);
23 + // Chained to the root, and the time block from the closed occurrence is
24 + // not carried onto a date nobody picked.
25 + assert_eq!(next.recurrence_parent_id, Some(task.id));
26 + assert_eq!(next.scheduled_start, None);
27 + assert_eq!(next.scheduled_duration, None);
28 +
29 + // An instance chains to the root it came from, not to its predecessor.
30 + let mut instance = task.clone();
31 + instance.id = crate::id_types::TaskId::new();
32 + instance.recurrence_parent_id = Some(task.id);
33 + let third = next_recurring_task(&instance, Tz::UTC, now).unwrap();
34 + assert_eq!(third.recurrence_parent_id, Some(task.id));
35 + }
36 +
37 + #[test]
38 + fn next_recurring_task_without_a_due_anchors_to_now() {
39 + // A floating chore: nothing to advance from, so the chain starts at the
40 + // moment it was completed rather than getting no due date at all.
41 + let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
42 + let mut task = recurring_task(now, Recurrence::Daily);
43 + task.due = None;
44 +
45 + let next = next_recurring_task(&task, Tz::UTC, now).expect("still recurs");
46 + let due = next.due.expect("anchored to now, not left empty");
47 + assert!(
48 + due > now,
49 + "successor is due after the completion, got {due}"
50 + );
51 + }
52 +
53 + #[test]
54 + fn next_recurring_task_is_none_for_a_one_off() {
55 + let now = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
56 + let task = recurring_task(now, Recurrence::None);
57 + assert!(next_recurring_task(&task, Tz::UTC, now).is_none());
58 + }
59 +
60 + #[test]
61 + fn test_daily_recurrence() {
62 + let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
63 + let next = calculate_next_due(Some(&now), &Recurrence::Daily).unwrap();
64 + assert_eq!(next.day(), 5);
65 + }
66 +
67 + #[test]
68 + fn test_weekly_recurrence() {
69 + let now = Utc.with_ymd_and_hms(2026, 2, 4, 10, 0, 0).unwrap();
70 + let next = calculate_next_due(Some(&now), &Recurrence::Weekly).unwrap();
71 + assert_eq!(next.day(), 11);
72 + }
73 +
74 + #[test]
75 + fn test_monthly_recurrence() {
76 + let now = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
77 + let next = calculate_next_due(Some(&now), &Recurrence::Monthly).unwrap();
78 + assert_eq!(next.month(), 2);
79 + assert_eq!(next.day(), 15);
80 + }
81 +
82 + #[test]
83 + fn test_monthly_end_of_month() {
84 + // Jan 31 -> Feb 28 (or 29 in leap year)
85 + let jan_31 = Utc.with_ymd_and_hms(2026, 1, 31, 10, 0, 0).unwrap();
86 + let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
87 + assert_eq!(next.month(), 2);
88 + // 2026 is not a leap year, so Feb has 28 days
89 + assert_eq!(next.day(), 28);
90 + }
91 +
92 + /// Fold `hops` completions, returning every due date in order.
93 + ///
94 + /// Recurrence is a chain: each completion re-derives from the previous
95 + /// instance's due date. Asserting a single hop from a fixed anchor passes even
96 + /// when the heuristic is wrong on the next one, which is exactly how the
97 + /// end-of-month drift survived a green suite.
98 + fn walk(start: DateTime<Utc>, recurrence: &Recurrence, hops: usize) -> Vec<(u32, u32)> {
99 + let mut out = Vec::new();
100 + let mut cur = start;
101 + for _ in 0..hops {
102 + cur = calculate_next_due(Some(&cur), recurrence).unwrap();
103 + out.push((cur.month(), cur.day()));
104 + }
105 + out
106 + }
107 +
108 + #[test]
109 + fn monthly_from_a_leap_february_stays_at_month_end() {
110 + // Feb 29 already snapped before the fix (29 was in range); pin it so the
111 + // two adjacent inputs cannot diverge again.
112 + let feb_29 = Utc.with_ymd_and_hms(2024, 2, 29, 10, 0, 0).unwrap();
113 + assert_eq!(
114 + walk(feb_29, &Recurrence::Monthly, 3),
115 + vec![(3, 31), (4, 30), (5, 31)]
116 + );
117 + }
118 +
119 + #[test]
120 + fn monthly_from_a_mid_month_day_does_not_drift_to_month_end() {
121 + // The guard must stay a month-end heuristic: day 15 is unambiguous and
122 + // must keep its day across the chain, including through February.
123 + let jan_15 = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
124 + assert_eq!(
125 + walk(jan_15, &Recurrence::Monthly, 3),
126 + vec![(2, 15), (3, 15), (4, 15)]
127 + );
128 + }
129 +
130 + #[test]
131 + fn monthly_from_a_non_leap_february_28_keeps_the_28th() {
132 + // Feb 28 in a non-leap year is ambiguous — the user may have meant "the
133 + // 28th" — so it must not be promoted to month-end intent.
134 + let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
135 + assert_eq!(
136 + walk(feb_28, &Recurrence::Monthly, 2),
137 + vec![(3, 28), (4, 28)]
138 + );
139 + }
140 +
141 + #[test]
142 + fn test_no_recurrence() {
143 + let now = Utc::now();
144 + let next = calculate_next_due(Some(&now), &Recurrence::None);
145 + assert!(next.is_none());
146 + }
147 +
148 + #[test]
149 + fn test_should_recur() {
150 + assert!(should_recur(&Recurrence::Daily));
151 + assert!(should_recur(&Recurrence::Weekly));
152 + assert!(should_recur(&Recurrence::Monthly));
153 + assert!(!should_recur(&Recurrence::None));
154 + }
155 +
156 + #[test]
157 + fn test_monthly_recurrence_preserves_time() {
158 + let original = Utc.with_ymd_and_hms(2026, 1, 15, 14, 30, 0).unwrap();
159 + let next = calculate_next_due(Some(&original), &Recurrence::Monthly).unwrap();
160 + assert_eq!(next.hour(), 14);
161 + assert_eq!(next.minute(), 30);
162 + }
163 +
164 + #[test]
165 + fn test_daily_recurrence_preserves_time() {
166 + let original = Utc.with_ymd_and_hms(2026, 2, 14, 9, 15, 30).unwrap();
167 + let next = calculate_next_due(Some(&original), &Recurrence::Daily).unwrap();
168 + assert_eq!(next.hour(), 9);
169 + assert_eq!(next.minute(), 15);
170 + assert_eq!(next.second(), 30);
171 + }
172 +
173 + #[test]
174 + fn test_weekly_recurrence_preserves_time() {
175 + let original = Utc.with_ymd_and_hms(2026, 3, 10, 17, 0, 0).unwrap();
176 + let next = calculate_next_due(Some(&original), &Recurrence::Weekly).unwrap();
177 + assert_eq!(next.hour(), 17);
178 + assert_eq!(next.minute(), 0);
179 + }
180 +
181 + #[test]
182 + fn test_monthly_december_to_january() {
183 + // Dec 15, 2026 -> Jan 15, 2027
184 + let dec_15 = Utc.with_ymd_and_hms(2026, 12, 15, 10, 0, 0).unwrap();
185 + let next = calculate_next_due(Some(&dec_15), &Recurrence::Monthly).unwrap();
186 + assert_eq!(next.year(), 2027);
187 + assert_eq!(next.month(), 1);
188 + assert_eq!(next.day(), 15);
189 + }
190 +
191 + #[test]
192 + fn test_monthly_leap_year() {
193 + // Jan 31, 2028 (leap year) -> Feb 29, 2028
194 + let jan_31 = Utc.with_ymd_and_hms(2028, 1, 31, 10, 0, 0).unwrap();
195 + let next = calculate_next_due(Some(&jan_31), &Recurrence::Monthly).unwrap();
196 + assert_eq!(next.month(), 2);
197 + assert_eq!(next.day(), 29); // 2028 is a leap year
198 + }
199 +
200 + #[test]
201 + fn test_monthly_feb_28_no_snap() {
202 + // Feb 28 in a non-leap year: day < 29, so no end-of-month snap.
203 + // User who chose the 28th gets Mar 28, not Mar 31.
204 + let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
205 + let next = calculate_next_due(Some(&feb_28), &Recurrence::Monthly).unwrap();
206 + assert_eq!(next.month(), 3);
207 + assert_eq!(next.day(), 28);
208 + }
209 +
210 + #[test]
211 + fn test_monthly_feb_28_explicit_day_28() {
212 + // With explicit target day 28, Feb 28 -> Mar 28 (no end-of-month heuristic)
213 + let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
214 + let next = calculate_next_due_with_day(Some(&feb_28), &Recurrence::Monthly, Some(28)).unwrap();
215 + assert_eq!(next.month(), 3);
216 + assert_eq!(next.day(), 28);
217 + }
218 +
219 + #[test]
220 + fn test_monthly_march_31_to_april() {
221 + // Mar 31 -> Apr 30 (April only has 30 days)
222 + let mar_31 = Utc.with_ymd_and_hms(2026, 3, 31, 10, 0, 0).unwrap();
223 + let next = calculate_next_due(Some(&mar_31), &Recurrence::Monthly).unwrap();
224 + assert_eq!(next.month(), 4);
225 + assert_eq!(next.day(), 30);
226 + }
227 +
228 + #[test]
229 + fn test_daily_year_boundary() {
230 + // Dec 31, 2026 -> Jan 1, 2027
231 + let dec_31 = Utc.with_ymd_and_hms(2026, 12, 31, 10, 0, 0).unwrap();
232 + let next = calculate_next_due(Some(&dec_31), &Recurrence::Daily).unwrap();
233 + assert_eq!(next.year(), 2027);
234 + assert_eq!(next.month(), 1);
235 + assert_eq!(next.day(), 1);
236 + }
237 +
238 + #[test]
239 + fn test_weekly_month_boundary() {
240 + // Jan 28, 2026 -> Feb 4, 2026
241 + let jan_28 = Utc.with_ymd_and_hms(2026, 1, 28, 10, 0, 0).unwrap();
242 + let next = calculate_next_due(Some(&jan_28), &Recurrence::Weekly).unwrap();
243 + assert_eq!(next.month(), 2);
244 + assert_eq!(next.day(), 4);
245 + }
246 +
247 + #[test]
248 + fn test_recurrence_with_no_due_date() {
249 + // When no due date provided, should use current time as base
250 + let next = calculate_next_due(None, &Recurrence::Daily);
251 + assert!(next.is_some());
252 +
253 + let next_date = next.unwrap();
254 + let now = Utc::now();
255 + // Next due should be approximately 1 day from now
256 + let diff = next_date - now;
257 + assert!(diff.num_hours() >= 23 && diff.num_hours() <= 25);
258 + }
259 +
260 + #[test]
261 + fn test_days_in_month_helper() {
262 + assert_eq!(days_in_month(2026, 1), 31); // January
263 + assert_eq!(days_in_month(2026, 2), 28); // February (non-leap)
264 + assert_eq!(days_in_month(2028, 2), 29); // February (leap year)
265 + assert_eq!(days_in_month(2026, 4), 30); // April
266 + assert_eq!(days_in_month(2026, 12), 31); // December
267 + }
268 +
269 + #[test]
270 + fn test_recurring_task_fresh_urgency_after_completion() {
271 + use crate::models::{Priority, TaskStatus};
272 + use crate::urgency::calculate_urgency;
273 +
274 + // Simulate an overdue recurring weekly task:
275 + // Original due date was 3 days ago, so it had high urgency from the overdue penalty.
276 + let overdue_due = Utc::now() - Duration::days(3);
277 + let old_created = Utc::now() - Duration::days(10);
278 + let tags: Vec<String> = vec![];
279 +
280 + let old_urgency = calculate_urgency(
281 + &Priority::Medium,
282 + &TaskStatus::Pending,
283 + Some(&overdue_due),
284 + &old_created,
285 + &tags,
286 + );
287 +
288 + // Old task should have overdue urgency (12.0 from overdue + priority + age)
289 + assert!(
290 + old_urgency > 15.0,
291 + "Overdue task should have high urgency, got: {old_urgency}"
292 + );
293 +
294 + // When completing and creating the next instance, we calculate next_due
295 + let next_due = calculate_next_due(Some(&overdue_due), &Recurrence::Weekly).unwrap();
296 + let new_created = Utc::now();
297 +
298 + let new_urgency = calculate_urgency(
299 + &Priority::Medium,
300 + &TaskStatus::Pending,
301 + Some(&next_due),
302 + &new_created,
303 + &tags,
304 + );
305 +
306 + // The new instance should NOT be overdue (due date is in the future)
307 + // and should have much lower urgency than the old overdue one
308 + assert!(
309 + new_urgency < old_urgency,
310 + "New recurring instance should have lower urgency ({new_urgency}) than the completed overdue one ({old_urgency})"
311 + );
312 +
313 + // Specifically, it should NOT have the overdue penalty
314 + assert!(
315 + new_urgency < 12.0,
316 + "New recurring instance should not have overdue penalty, got urgency: {new_urgency}"
317 + );
318 + }
319 +
320 + // Rich Recurrence Tests
321 +
322 + #[test]
323 + fn test_rich_daily_interval() {
324 + let now = Utc.with_ymd_and_hms(2026, 3, 1, 9, 0, 0).unwrap();
325 + let rule = RecurrenceRule {
326 + pattern: Recurrence::Daily,
327 + interval: 3,
328 + weekdays: vec![],
329 + monthly_spec: None,
330 + until: None,
331 + };
332 + let next = calculate_next_due_rich(Some(&now), &rule).unwrap();
333 + assert_eq!(next.day(), 4); // 3 days later
334 + assert_eq!(next.hour(), 9);
335 + }
336 +
337 + #[test]
338 + fn test_rich_weekly_weekdays() {
339 + // Monday, requesting Mon/Wed/Fri
340 + let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
341 + let rule = RecurrenceRule {
342 + pattern: Recurrence::Weekly,
343 + interval: 1,
344 + weekdays: vec![0, 2, 4], // Mon, Wed, Fri
345 + monthly_spec: None,
346 + until: None,
347 + };
348 + // Next after Monday should be Wednesday
349 + let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
350 + assert_eq!(next.weekday(), chrono::Weekday::Wed);
351 + assert_eq!(next.day(), 4);
352 +
353 + // Next after Wednesday should be Friday
354 + let next2 = calculate_next_due_rich(Some(&next), &rule).unwrap();
355 + assert_eq!(next2.weekday(), chrono::Weekday::Fri);
356 + assert_eq!(next2.day(), 6);
357 +
358 + // Next after Friday should be Monday of next week
359 + let next3 = calculate_next_due_rich(Some(&next2), &rule).unwrap();
360 + assert_eq!(next3.weekday(), chrono::Weekday::Mon);
361 + assert_eq!(next3.day(), 9);
362 + }
363 +
364 + #[test]
365 + fn test_rich_weekly_ignores_out_of_range_weekdays() {
366 + // A corrupt/imported weekday byte (200) must not drive a ~200-day jump;
367 + // out-of-range values are dropped, leaving the valid weekday (Wed).
368 + let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
369 + let rule = RecurrenceRule {
370 + pattern: Recurrence::Weekly,
371 + interval: 1,
372 + weekdays: vec![200, 2], // garbage + Wed
373 + monthly_spec: None,
374 + until: None,
375 + };
376 + let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
377 + assert_eq!(next.weekday(), chrono::Weekday::Wed);
378 + assert!(
379 + (next - mon).num_days() < 7,
380 + "must not jump far past one week"
381 + );
382 + }
383 +
384 + #[test]
385 + fn test_rich_weekly_all_invalid_weekdays_falls_back() {
386 + // If every weekday byte is invalid, advance by the interval-week instead
387 + // of panicking on an empty sorted list.
388 + let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
389 + let rule = RecurrenceRule {
390 + pattern: Recurrence::Weekly,
391 + interval: 1,
392 + weekdays: vec![99, 200],
393 + monthly_spec: None,
394 + until: None,
395 + };
396 + let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
397 + assert_eq!((next - mon).num_days(), 7);
398 + }
399 +
400 + #[test]
401 + fn test_rich_interval_clamped() {
402 + // An absurd interval must not overflow the i32 month cast or civil math.
403 + let mon = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
404 + let rule = RecurrenceRule {
405 + pattern: Recurrence::Daily,
406 + interval: u32::MAX,
407 + weekdays: vec![],
408 + monthly_spec: None,
409 + until: None,
410 + };
411 + // Clamped to 10_000 days; just assert it produces a finite future date.
412 + let next = calculate_next_due_rich(Some(&mon), &rule).unwrap();
413 + assert!(next > mon);
414 + }
415 +
416 + #[test]
417 + fn test_rich_weekly_interval_2() {
418 + // Friday, every 2 weeks on Mon/Fri
419 + let fri = Utc.with_ymd_and_hms(2026, 3, 6, 10, 0, 0).unwrap(); // Friday
420 + let rule = RecurrenceRule {
421 + pattern: Recurrence::Weekly,
422 + interval: 2,
423 + weekdays: vec![0, 4], // Mon, Fri
424 + monthly_spec: None,
425 + until: None,
426 + };
427 + // Next after Friday: wrap to Mon of 2-weeks-later
428 + let next = calculate_next_due_rich(Some(&fri), &rule).unwrap();
429 + assert_eq!(next.weekday(), chrono::Weekday::Mon);
430 + assert_eq!(next.day(), 16); // 2 weeks later, Monday
431 + }
432 +
433 + #[test]
434 + fn test_rich_monthly_day_of_month() {
435 + let jan = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
436 + let rule = RecurrenceRule {
437 + pattern: Recurrence::Monthly,
438 + interval: 1,
439 + weekdays: vec![],
440 + monthly_spec: Some(MonthlySpec::DayOfMonth { day: 15 }),
441 + until: None,
442 + };
443 + let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
444 + assert_eq!(next.month(), 2);
445 + assert_eq!(next.day(), 15);
446 + }
447 +
448 + #[test]
449 + fn test_rich_monthly_nth_weekday() {
450 + // 2nd Friday of January 2026 is Jan 9... let me compute
451 + // Jan 2026: 1=Thu, 2=Fri (1st Fri), 9=Fri (2nd Fri)
452 + let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap();
453 + let rule = RecurrenceRule {
454 + pattern: Recurrence::Monthly,
455 + interval: 1,
456 + weekdays: vec![],
457 + monthly_spec: Some(MonthlySpec::NthWeekday {
458 + week: 2,
459 + weekday: 4,
460 + }), // 2nd Friday
461 + until: None,
462 + };
463 + let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
464 + // Feb 2026: 1=Sun, 6=Fri (1st Fri), 13=Fri (2nd Fri)
465 + assert_eq!(next.month(), 2);
466 + assert_eq!(next.day(), 13);
467 + }
468 +
469 + #[test]
470 + fn test_rich_monthly_last_weekday() {
471 + let jan = Utc.with_ymd_and_hms(2026, 1, 26, 10, 0, 0).unwrap();
472 + let rule = RecurrenceRule {
473 + pattern: Recurrence::Monthly,
474 + interval: 1,
475 + weekdays: vec![],
476 + monthly_spec: Some(MonthlySpec::NthWeekday {
477 + week: -1,
478 + weekday: 0,
479 + }), // Last Monday
480 + until: None,
481 + };
482 + let next = calculate_next_due_rich(Some(&jan), &rule).unwrap();
483 + // Feb 2026: last Monday is Feb 23
484 + assert_eq!(next.month(), 2);
485 + assert_eq!(next.day(), 23);
486 + }
487 +
488 + #[test]
489 + fn test_rich_monthly_nth_weekday_rejects_out_of_range_week() {
490 + let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap();
491 + for week in [0i8, 6, 7, -2, i8::MIN, i8::MAX] {
492 + let rule = RecurrenceRule {
493 + pattern: Recurrence::Monthly,
494 + interval: 1,
495 + weekdays: vec![],
496 + monthly_spec: Some(MonthlySpec::NthWeekday { week, weekday: 4 }),
497 + until: None,
498 + };
499 + // Previously this silently yielded the un-adjusted base (Feb 9).
500 + assert_eq!(
Lines truncated