Skip to main content

max / goingson

31.6 KB · 921 lines History Blame Raw
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!(
501 calculate_next_due_rich(Some(&jan), &rule),
502 None,
503 "week {week} should be rejected, not silently ignored"
504 );
505 }
506 }
507
508 #[test]
509 fn test_rich_monthly_nth_weekday_rejects_out_of_range_weekday() {
510 let jan = Utc.with_ymd_and_hms(2026, 1, 9, 10, 0, 0).unwrap();
511 let rule = RecurrenceRule {
512 pattern: Recurrence::Monthly,
513 interval: 1,
514 weekdays: vec![],
515 monthly_spec: Some(MonthlySpec::NthWeekday {
516 week: 2,
517 weekday: 200,
518 }),
519 until: None,
520 };
521 assert_eq!(calculate_next_due_rich(Some(&jan), &rule), None);
522 }
523
524 #[test]
525 fn test_rich_monthly_nth_weekday_skips_months_without_occurrence() {
526 // 5th Monday: Mar 2026 has one (Mar 30), Apr and May 2026 do not,
527 // Jun 2026 does (Jun 29). The gap months must be skipped, not
528 // collapsed onto the base date.
529 let mar = Utc.with_ymd_and_hms(2026, 3, 30, 10, 0, 0).unwrap();
530 let rule = RecurrenceRule {
531 pattern: Recurrence::Monthly,
532 interval: 1,
533 weekdays: vec![],
534 monthly_spec: Some(MonthlySpec::NthWeekday {
535 week: 5,
536 weekday: 0,
537 }),
538 until: None,
539 };
540 let next = calculate_next_due_rich(Some(&mar), &rule).unwrap();
541 assert_eq!((next.month(), next.day()), (6, 29));
542 }
543
544 /// A one-hour weekly event, optionally bounded. Written as a helper because
545 /// the `Event` literal is 25 fields and every expansion test wants the same
546 /// one; a new field on `Event` should not mean editing four tests.
547 fn weekly_event(start: DateTime<Utc>, until: Option<DateTime<Utc>>) -> Event {
548 Event {
549 id: crate::id_types::EventId::new(),
550 user_id: None,
551 project_id: None,
552 project_name: None,
553 contact_id: None,
554 contact_name: None,
555 title: "Weekly meeting".to_string(),
556 description: String::new(),
557 start_time: start,
558 end_time: Some(start + Duration::hours(1)),
559 location: None,
560 linked_task_id: None,
561 recurrence: Recurrence::Weekly,
562 recurrence_rule: Some(RecurrenceRule {
563 pattern: Recurrence::Weekly,
564 interval: 1,
565 weekdays: vec![],
566 monthly_spec: None,
567 until,
568 }),
569 recurrence_parent_id: None,
570 is_recurring_instance: false,
571 block_type: None,
572 external_source: None,
573 external_id: None,
574 is_read_only: false,
575 snoozed_until: None,
576 reminder_offsets_seconds: Vec::new(),
577 tz_kind: crate::models::TzKind::Absolute,
578 timezone: None,
579 start_local: None,
580 end_local: None,
581 }
582 }
583
584 #[test]
585 fn test_expand_recurrence_weekly() {
586 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap(); // Monday
587 let event = weekly_event(start, None);
588
589 let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
590 let range_end = Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap();
591
592 let instances = expand_recurrence(&event, range_start, range_end);
593 // Original is March 2 (Mon). Instances: Mar 9, 16, 23, 30 = 4 expanded
594 assert_eq!(instances.len(), 4);
595 assert_eq!(instances[0].start_time.day(), 9);
596 assert_eq!(instances[1].start_time.day(), 16);
597 assert_eq!(instances[2].start_time.day(), 23);
598 assert_eq!(instances[3].start_time.day(), 30);
599
600 // All should be marked as recurring instances
601 assert!(instances.iter().all(|e| e.is_recurring_instance));
602 // All should have unique deterministic IDs
603 let ids: std::collections::HashSet<_> = instances.iter().map(|e| e.id).collect();
604 assert_eq!(ids.len(), 4);
605 }
606
607 #[test]
608 fn test_expand_recurrence_far_past_start_still_renders() {
609 // A daily event whose start_time is well over 500 occurrences before the
610 // window used to render empty: the 500-iteration budget was spent on
611 // occurrences long before range_start. The seek must fast-forward into
612 // the window so today's occurrences appear.
613 let start = Utc.with_ymd_and_hms(2022, 1, 1, 9, 0, 0).unwrap(); // ~4 years prior
614 let event = Event {
615 id: crate::id_types::EventId::new(),
616 user_id: None,
617 project_id: None,
618 project_name: None,
619 contact_id: None,
620 contact_name: None,
621 title: "Daily standup".to_string(),
622 description: String::new(),
623 start_time: start,
624 end_time: Some(start + Duration::minutes(15)),
625 location: None,
626 linked_task_id: None,
627 recurrence: Recurrence::Daily,
628 recurrence_rule: Some(RecurrenceRule {
629 pattern: Recurrence::Daily,
630 interval: 1,
631 weekdays: vec![],
632 monthly_spec: None,
633 until: None,
634 }),
635 recurrence_parent_id: None,
636 is_recurring_instance: false,
637 block_type: None,
638 external_source: None,
639 external_id: None,
640 is_read_only: false,
641 snoozed_until: None,
642 reminder_offsets_seconds: Vec::new(),
643 tz_kind: crate::models::TzKind::Absolute,
644 timezone: None,
645 start_local: None,
646 end_local: None,
647 };
648
649 let range_start = Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap();
650 let range_end = Utc.with_ymd_and_hms(2026, 3, 7, 23, 59, 59).unwrap();
651
652 let instances = expand_recurrence(&event, range_start, range_end);
653 // Seven days in the window, each with a daily occurrence.
654 assert_eq!(
655 instances.len(),
656 7,
657 "old daily event must still render in the current window"
658 );
659 assert!(
660 instances
661 .iter()
662 .all(|e| e.start_time >= range_start && e.start_time <= range_end)
663 );
664 }
665
666 #[test]
667 fn test_expand_recurrence_deterministic_ids() {
668 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
669 let event = Event {
670 id: crate::id_types::EventId::new(),
671 user_id: None,
672 project_id: None,
673 project_name: None,
674 contact_id: None,
675 contact_name: None,
676 title: "Test".to_string(),
677 description: String::new(),
678 start_time: start,
679 end_time: Some(start + Duration::hours(1)),
680 location: None,
681 linked_task_id: None,
682 recurrence: Recurrence::Daily,
683 recurrence_rule: None,
684 recurrence_parent_id: None,
685 is_recurring_instance: false,
686 block_type: None,
687 external_source: None,
688 external_id: None,
689 is_read_only: false,
690 snoozed_until: None,
691 reminder_offsets_seconds: Vec::new(),
692 tz_kind: crate::models::TzKind::Absolute,
693 timezone: None,
694 start_local: None,
695 end_local: None,
696 };
697
698 let range_start = Utc.with_ymd_and_hms(2026, 3, 3, 0, 0, 0).unwrap();
699 let range_end = Utc.with_ymd_and_hms(2026, 3, 5, 23, 59, 59).unwrap();
700
701 let instances1 = expand_recurrence(&event, range_start, range_end);
702 let instances2 = expand_recurrence(&event, range_start, range_end);
703 // Same inputs produce same IDs
704 assert_eq!(instances1.len(), instances2.len());
705 for (a, b) in instances1.iter().zip(instances2.iter()) {
706 assert_eq!(a.id, b.id);
707 }
708 }
709
710 // DST / time-zone-aware recurrence (Run #28)
711
712 #[test]
713 fn test_daily_recurrence_holds_local_time_across_spring_forward() {
714 use chrono_tz::America::New_York;
715 // 2026-03-08 is US spring-forward (02:00 -> 03:00). A task at 09:00 local on
716 // Mar 7 must land at 09:00 local on Mar 8, not 10:00 as fixed-24h-UTC would give.
717 let start = New_York
718 .with_ymd_and_hms(2026, 3, 7, 9, 0, 0)
719 .single()
720 .unwrap()
721 .with_timezone(&Utc);
722 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
723 let next_local = next.with_timezone(&New_York);
724 assert_eq!(next_local.day(), 8);
725 assert_eq!(
726 next_local.hour(),
727 9,
728 "local hour must stay 09:00 across DST"
729 );
730 // The UTC instant shifts by 23h (a short civil day), proving DST was honored.
731 assert_eq!((next - start).num_hours(), 23);
732 }
733
734 #[test]
735 fn test_daily_recurrence_holds_local_time_across_fall_back() {
736 use chrono_tz::America::New_York;
737 // 2026-11-01 is US fall-back (02:00 -> 01:00). 09:00 local Oct 31 -> 09:00 local Nov 1.
738 let start = New_York
739 .with_ymd_and_hms(2026, 10, 31, 9, 0, 0)
740 .single()
741 .unwrap()
742 .with_timezone(&Utc);
743 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
744 let next_local = next.with_timezone(&New_York);
745 assert_eq!(next_local.day(), 1);
746 assert_eq!(next_local.hour(), 9);
747 assert_eq!(
748 (next - start).num_hours(),
749 25,
750 "a long civil day spans the fall-back"
751 );
752 }
753
754 #[test]
755 fn test_weekly_recurrence_holds_local_time_across_dst() {
756 use chrono_tz::America::New_York;
757 // Mar 5 (Thu) 08:00 local -> Mar 12, still 08:00 local, despite the Mar 8 transition.
758 let start = New_York
759 .with_ymd_and_hms(2026, 3, 5, 8, 0, 0)
760 .single()
761 .unwrap()
762 .with_timezone(&Utc);
763 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Weekly, New_York).unwrap();
764 let next_local = next.with_timezone(&New_York);
765 assert_eq!(next_local.day(), 12);
766 assert_eq!(next_local.hour(), 8);
767 }
768
769 #[test]
770 fn test_monthly_recurrence_holds_local_time_across_dst() {
771 use chrono_tz::America::New_York;
772 // Feb 20 09:00 local -> Mar 20 09:00 local, crossing the Mar 8 spring-forward.
773 let start = New_York
774 .with_ymd_and_hms(2026, 2, 20, 9, 0, 0)
775 .single()
776 .unwrap()
777 .with_timezone(&Utc);
778 let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Monthly, New_York).unwrap();
779 let next_local = next.with_timezone(&New_York);
780 assert_eq!(next_local.month(), 3);
781 assert_eq!(next_local.day(), 20);
782 assert_eq!(next_local.hour(), 9);
783 }
784
785 // A bounded series: `until` ends it, inclusive of an occurrence landing
786 // exactly on the boundary.
787
788 #[test]
789 fn until_includes_an_occurrence_landing_exactly_on_it() {
790 // Mon Mar 2, weekly, bounded at Mar 23 10:00 — the fourth occurrence to
791 // the minute. Mar 9, 16 and 23 expand; Mar 30 does not.
792 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
793 let until = Utc.with_ymd_and_hms(2026, 3, 23, 10, 0, 0).unwrap();
794 let event = weekly_event(start, Some(until));
795
796 let instances = expand_recurrence(
797 &event,
798 Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(),
799 Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(),
800 );
801
802 let days: Vec<u32> = instances.iter().map(|e| e.start_time.day()).collect();
803 assert_eq!(days, vec![9, 16, 23]);
804 }
805
806 #[test]
807 fn an_occurrence_one_second_past_until_is_excluded() {
808 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
809 // One second before the Mar 23 occurrence, so only Mar 9 and 16 survive.
810 let until = Utc.with_ymd_and_hms(2026, 3, 23, 9, 59, 59).unwrap();
811 let event = weekly_event(start, Some(until));
812
813 let instances = expand_recurrence(
814 &event,
815 Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(),
816 Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(),
817 );
818
819 let days: Vec<u32> = instances.iter().map(|e| e.start_time.day()).collect();
820 assert_eq!(days, vec![9, 16]);
821 }
822
823 #[test]
824 fn until_before_the_first_occurrence_expands_to_nothing() {
825 // A rule that ends before it begins is a caller mistake the wire layer
826 // cannot catch (it never sees the start_time), so the honest reading is
827 // an empty series rather than an infinite one.
828 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
829 let until = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
830 let event = weekly_event(start, Some(until));
831
832 let instances = expand_recurrence(
833 &event,
834 Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(),
835 Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(),
836 );
837
838 assert!(instances.is_empty());
839 }
840
841 #[test]
842 fn until_stops_the_seek_rather_than_burning_the_budget() {
843 // The window opens years after a bounded series closed. The seek loop
844 // has to notice `until` too: without the check it walks toward
845 // range_start one occurrence at a time, up to the 100k seek cap, and
846 // then renders empty by accident rather than on purpose.
847 let start = Utc.with_ymd_and_hms(2020, 1, 6, 10, 0, 0).unwrap();
848 let until = Utc.with_ymd_and_hms(2020, 3, 2, 10, 0, 0).unwrap();
849 let event = weekly_event(start, Some(until));
850
851 let instances = expand_recurrence(
852 &event,
853 Utc.with_ymd_and_hms(2026, 3, 1, 0, 0, 0).unwrap(),
854 Utc.with_ymd_and_hms(2026, 3, 31, 23, 59, 59).unwrap(),
855 );
856
857 assert!(instances.is_empty());
858 }
859
860 #[test]
861 fn a_rule_with_no_until_still_repeats_forever() {
862 // The default every rule written before this field existed reads back as.
863 let start = Utc.with_ymd_and_hms(2026, 3, 2, 10, 0, 0).unwrap();
864 let event = weekly_event(start, None);
865
866 let instances = expand_recurrence(
867 &event,
868 Utc.with_ymd_and_hms(2030, 3, 1, 0, 0, 0).unwrap(),
869 Utc.with_ymd_and_hms(2030, 3, 31, 23, 59, 59).unwrap(),
870 );
871
872 assert_eq!(instances.len(), 4);
873 }
874
875 #[test]
876 fn next_recurring_task_opens_no_successor_past_until() {
877 // The final instance closes the chain: completing it writes no successor,
878 // which is what makes a bounded series stop without anyone deleting it.
879 let due = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
880 let mut task = recurring_task(due, Recurrence::Weekly);
881 task.recurrence_rule = Some(RecurrenceRule {
882 pattern: Recurrence::Weekly,
883 interval: 1,
884 weekdays: vec![],
885 monthly_spec: None,
886 // The successor would fall on Aug 10, one day past this.
887 until: Some(Utc.with_ymd_and_hms(2026, 8, 9, 9, 0, 0).unwrap()),
888 });
889
890 assert!(next_recurring_task(&task, Tz::UTC, due).is_none());
891 }
892
893 #[test]
894 fn next_recurring_task_opens_a_successor_landing_on_until() {
895 // Same inclusivity as the expansion side: the boundary occurrence is
896 // part of the series.
897 let due = Utc.with_ymd_and_hms(2026, 8, 3, 9, 0, 0).unwrap();
898 let until = Utc.with_ymd_and_hms(2026, 8, 10, 9, 0, 0).unwrap();
899 let mut task = recurring_task(due, Recurrence::Weekly);
900 task.recurrence_rule = Some(RecurrenceRule {
901 pattern: Recurrence::Weekly,
902 interval: 1,
903 weekdays: vec![],
904 monthly_spec: None,
905 until: Some(until),
906 });
907
908 let next = next_recurring_task(&task, Tz::UTC, due).expect("successor on the boundary");
909 assert_eq!(next.due, Some(until));
910 }
911
912 #[test]
913 fn test_utc_wrapper_unaffected_by_dst_logic() {
914 // The legacy UTC entry points must still add a fixed 24h (no zone involved),
915 // so existing callers and instants are unchanged.
916 let start = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap();
917 let next = calculate_next_due(Some(&start), &Recurrence::Daily).unwrap();
918 assert_eq!((next - start).num_hours(), 24);
919 assert_eq!(next.hour(), 9);
920 }
921