| 44 |
44 |
|
// If the due date falls on the last day of its month AND the day is >= 29,
|
| 45 |
45 |
|
// treat the target as day 31 so it snaps to end-of-month in longer months.
|
| 46 |
46 |
|
// The >= 29 guard prevents false positives: Feb 28 in a non-leap year is
|
| 47 |
|
- |
// ambiguous (user may have meant "the 28th"), but days 29-30 at month-end
|
| 48 |
|
- |
// clearly indicate end-of-month intent. Day/month are read in `tz`.
|
|
47 |
+ |
// ambiguous (user may have meant "the 28th"), but days 29-31 at month-end
|
|
48 |
+ |
// clearly indicate end-of-month intent. The range is inclusive of 31: an
|
|
49 |
+ |
// exclusive `29..31` dropped the least ambiguous end-of-month date there is,
|
|
50 |
+ |
// so Jan 31 clamped to Feb 28 and then re-derived from day 28 — below the
|
|
51 |
+ |
// guard — and stayed three days early for the rest of the chain, while a leap
|
|
52 |
+ |
// Feb 29 snapped correctly. Day/month are read in `tz`.
|
| 49 |
53 |
|
let target_day = if matches!(recurrence, Recurrence::Monthly) {
|
| 50 |
54 |
|
current_due.and_then(|dt| {
|
| 51 |
55 |
|
let local = dt.with_timezone(&tz);
|
| 52 |
56 |
|
let day = local.day();
|
| 53 |
57 |
|
let month_len = days_in_month(local.year(), local.month());
|
| 54 |
|
- |
if day == month_len && (29..31).contains(&day) {
|
|
58 |
+ |
if day == month_len && (29..=31).contains(&day) {
|
| 55 |
59 |
|
Some(31)
|
| 56 |
60 |
|
} else {
|
| 57 |
61 |
|
None
|
| 273 |
277 |
|
let target_day = {
|
| 274 |
278 |
|
let day = local.day();
|
| 275 |
279 |
|
let month_len = days_in_month(local.year(), local.month());
|
| 276 |
|
- |
if day == month_len && (29..31).contains(&day) {
|
|
280 |
+ |
if day == month_len && (29..=31).contains(&day) {
|
| 277 |
281 |
|
Some(31)
|
| 278 |
282 |
|
} else {
|
| 279 |
283 |
|
None
|
| 605 |
609 |
|
assert_eq!(next.day(), 28);
|
| 606 |
610 |
|
}
|
| 607 |
611 |
|
|
|
612 |
+ |
/// Fold `hops` completions, returning every due date in order.
|
|
613 |
+ |
///
|
|
614 |
+ |
/// Recurrence is a chain: each completion re-derives from the previous
|
|
615 |
+ |
/// instance's due date. Asserting a single hop from a fixed anchor passes even
|
|
616 |
+ |
/// when the heuristic is wrong on the next one, which is exactly how the
|
|
617 |
+ |
/// end-of-month drift survived a green suite.
|
|
618 |
+ |
fn walk(start: DateTime<Utc>, recurrence: &Recurrence, hops: usize) -> Vec<(u32, u32)> {
|
|
619 |
+ |
let mut out = Vec::new();
|
|
620 |
+ |
let mut cur = start;
|
|
621 |
+ |
for _ in 0..hops {
|
|
622 |
+ |
cur = calculate_next_due(Some(&cur), recurrence).unwrap();
|
|
623 |
+ |
out.push((cur.month(), cur.day()));
|
|
624 |
+ |
}
|
|
625 |
+ |
out
|
|
626 |
+ |
}
|
|
627 |
+ |
|
|
628 |
+ |
#[test]
|
|
629 |
+ |
fn monthly_from_a_leap_february_stays_at_month_end() {
|
|
630 |
+ |
// Feb 29 already snapped before the fix (29 was in range); pin it so the
|
|
631 |
+ |
// two adjacent inputs cannot diverge again.
|
|
632 |
+ |
let feb_29 = Utc.with_ymd_and_hms(2024, 2, 29, 10, 0, 0).unwrap();
|
|
633 |
+ |
assert_eq!(
|
|
634 |
+ |
walk(feb_29, &Recurrence::Monthly, 3),
|
|
635 |
+ |
vec![(3, 31), (4, 30), (5, 31)]
|
|
636 |
+ |
);
|
|
637 |
+ |
}
|
|
638 |
+ |
|
|
639 |
+ |
#[test]
|
|
640 |
+ |
fn monthly_from_a_mid_month_day_does_not_drift_to_month_end() {
|
|
641 |
+ |
// The guard must stay a month-end heuristic: day 15 is unambiguous and
|
|
642 |
+ |
// must keep its day across the chain, including through February.
|
|
643 |
+ |
let jan_15 = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
|
|
644 |
+ |
assert_eq!(
|
|
645 |
+ |
walk(jan_15, &Recurrence::Monthly, 3),
|
|
646 |
+ |
vec![(2, 15), (3, 15), (4, 15)]
|
|
647 |
+ |
);
|
|
648 |
+ |
}
|
|
649 |
+ |
|
|
650 |
+ |
#[test]
|
|
651 |
+ |
fn monthly_from_a_non_leap_february_28_keeps_the_28th() {
|
|
652 |
+ |
// Feb 28 in a non-leap year is ambiguous — the user may have meant "the
|
|
653 |
+ |
// 28th" — so it must not be promoted to month-end intent.
|
|
654 |
+ |
let feb_28 = Utc.with_ymd_and_hms(2026, 2, 28, 10, 0, 0).unwrap();
|
|
655 |
+ |
assert_eq!(
|
|
656 |
+ |
walk(feb_28, &Recurrence::Monthly, 2),
|
|
657 |
+ |
vec![(3, 28), (4, 28)]
|
|
658 |
+ |
);
|
|
659 |
+ |
}
|
|
660 |
+ |
|
| 608 |
661 |
|
#[test]
|
| 609 |
662 |
|
fn test_no_recurrence() {
|
| 610 |
663 |
|
let now = Utc::now();
|