1use std::time::{Duration, Instant};
2
3use chrono::{Datelike, Local, Timelike};
4
5use super::types::{BreakDelivery, BreakKind};
6
7#[derive(Debug)]
13pub struct BreakTimers {
14 pub last_micro: Instant,
15 pub last_long: Instant,
16 pub last_sleep: Option<Instant>,
17 pub micro_warned: bool,
18 pub long_warned: bool,
19 pub active_break: Option<BreakKind>,
20 pub micro_deferred_since: Option<Instant>,
21 pub long_deferred_since: Option<Instant>,
22 pub micro_postpone_count: u32,
23 pub long_postpone_count: u32,
24 pub last_skipped_or_postponed: Option<(BreakKind, Instant)>,
25 pub last_micro_fixed_fire: Option<(String, u32)>,
31 pub last_long_fixed_fire: Option<(String, u32)>,
32}
33
34impl BreakTimers {
35 pub fn new() -> Self {
38 let now = Instant::now();
39 Self {
40 last_micro: now,
41 last_long: now,
42 last_sleep: None,
43 micro_warned: false,
44 long_warned: false,
45 active_break: None,
46 micro_deferred_since: None,
47 long_deferred_since: None,
48 micro_postpone_count: 0,
49 long_postpone_count: 0,
50 last_skipped_or_postponed: None,
51 last_micro_fixed_fire: None,
52 last_long_fixed_fire: None,
53 }
54 }
55}
56
57pub fn reanchor_intervals_to(t: &mut BreakTimers, now: Instant) {
68 t.last_micro = now;
69 t.last_long = now;
70 t.micro_warned = false;
71 t.long_warned = false;
72 t.micro_deferred_since = None;
73 t.long_deferred_since = None;
74 t.micro_postpone_count = 0;
75 t.long_postpone_count = 0;
76}
77
78pub fn reset_timers_keep_sleep(t: &mut BreakTimers) {
83 reanchor_intervals_to(t, Instant::now());
84}
85
86pub fn reanchor_intervals_on_resume(t: &mut BreakTimers, now: Instant) {
100 reanchor_intervals_to(t, now);
101}
102
103pub fn record_scheduled_fire(
121 t: &mut BreakTimers,
122 kind: BreakKind,
123 delivery: BreakDelivery,
124 now: Instant,
125 fixed_key: Option<(String, u32)>,
126) {
127 match kind {
128 BreakKind::Long => {
129 t.last_long = now;
130 t.last_micro = now;
131 t.long_warned = false;
132 t.micro_warned = false;
133 t.long_deferred_since = None;
134 t.micro_deferred_since = None;
135 if let Some(key) = fixed_key {
136 t.last_long_fixed_fire = Some(key);
137 }
138 }
139 BreakKind::Micro => {
140 t.last_micro = now;
141 t.micro_warned = false;
142 t.micro_deferred_since = None;
143 if let Some(key) = fixed_key {
144 t.last_micro_fixed_fire = Some(key);
145 }
146 }
147 BreakKind::Sleep => {}
148 }
149 if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
150 t.active_break = Some(kind);
151 }
152}
153
154pub fn clear_last_break(t: &mut BreakTimers) -> bool {
158 if t.last_skipped_or_postponed.is_some() {
159 t.last_skipped_or_postponed = None;
160 true
161 } else {
162 false
163 }
164}
165
166pub fn current_minutes() -> u32 {
170 let now = Local::now();
171 now.hour() * 60 + now.minute()
172}
173
174pub fn local_today_string() -> String {
177 Local::now().date_naive().format("%Y-%m-%d").to_string()
178}
179
180pub fn current_weekday() -> u32 {
184 Local::now().weekday().num_days_from_monday()
185}
186
187pub fn parse_hhmm(s: &str) -> Option<u32> {
191 let trimmed = s.trim();
192 let (h_str, m_str) = trimmed.split_once(':')?;
193 if h_str.is_empty() || m_str.len() != 2 {
194 return None;
195 }
196 let h: u32 = h_str.parse().ok()?;
197 let m: u32 = m_str.parse().ok()?;
198 if h >= 24 || m >= 60 {
199 return None;
200 }
201 Some(h * 60 + m)
202}
203
204pub fn should_fire_fixed_now(
211 today: &str,
212 current_min: u32,
213 last_fire: Option<&(String, u32)>,
214) -> bool {
215 match last_fire {
216 Some((day, prev_min)) => day != today || *prev_min != current_min,
217 None => true,
218 }
219}
220
221pub fn in_window(now: u32, start: u32, end: u32) -> bool {
225 if start == end {
226 return false;
227 }
228 if start < end {
229 now >= start && now < end
230 } else {
231 now >= start || now < end
232 }
233}
234
235pub fn work_window_active(now: u32, start: u32, end: u32, today: u32, days_mask: u8) -> bool {
250 if !in_window(now, start, end) {
251 return false;
252 }
253 let window_day = if start <= end || now >= start {
254 today
255 } else {
256 (today + 6) % 7
257 };
258 days_mask & (1 << window_day) != 0
259}
260
261pub fn interval_break_due(
271 enabled: bool,
272 mode_includes_interval: bool,
273 last_fire: Instant,
274 interval_secs: u64,
275 idle_suppressed: bool,
276 now: Instant,
277) -> bool {
278 enabled
279 && mode_includes_interval
280 && !idle_suppressed
281 && now.saturating_duration_since(last_fire) >= Duration::from_secs(interval_secs)
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub struct PrebreakGate {
290 pub enabled: bool,
291 pub mode_includes_interval: bool,
292 pub already_warned: bool,
293 pub idle_suppressed: bool,
294}
295
296pub fn prebreak_warn_due(
306 gate: PrebreakGate,
307 last_fire: Instant,
308 interval_secs: u64,
309 lead_secs: u64,
310 now: Instant,
311) -> bool {
312 if !gate.enabled || !gate.mode_includes_interval || gate.idle_suppressed || gate.already_warned
313 {
314 return false;
315 }
316 let interval = Duration::from_secs(interval_secs);
317 let lead = Duration::from_secs(lead_secs);
318 let warn_at = interval.saturating_sub(lead);
319 let elapsed = now.saturating_duration_since(last_fire);
320 elapsed >= warn_at && elapsed < interval
321}
322
323#[derive(Debug, PartialEq, Eq, Clone, Copy)]
327pub enum BedtimeAction {
328 Fire,
330 ResetTimersOnly,
334 NotInWindow,
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub struct BedtimeWindow {
344 pub enabled: bool,
345 pub start_min: u32,
346 pub end_min: u32,
347 pub interval_secs: u64,
348}
349
350pub fn decide_bedtime(
366 window: BedtimeWindow,
367 now_min: u32,
368 last_sleep_fire: Option<Instant>,
369 now: Instant,
370 resumed_from_suspend: bool,
371) -> BedtimeAction {
372 if !window.enabled || !in_window(now_min, window.start_min, window.end_min) {
373 return BedtimeAction::NotInWindow;
374 }
375 let should_fire = match last_sleep_fire {
376 None => true,
377 Some(t) => now.saturating_duration_since(t) >= Duration::from_secs(window.interval_secs),
378 };
379 if should_fire {
380 if resumed_from_suspend && last_sleep_fire.is_some() {
381 return BedtimeAction::ResetTimersOnly;
382 }
383 BedtimeAction::Fire
384 } else {
385 BedtimeAction::ResetTimersOnly
386 }
387}
388
389pub fn should_defer_for_typing(
403 enabled: bool,
404 idle_secs: Option<u64>,
405 grace_secs: u64,
406 deferred_since: Option<Instant>,
407 max_deferral_secs: u64,
408 now: Instant,
409) -> bool {
410 if !enabled || grace_secs == 0 {
411 return false;
412 }
413 let Some(idle_secs) = idle_secs else {
414 return false;
415 };
416 if idle_secs >= grace_secs {
417 return false;
418 }
419 match deferred_since {
420 None => true,
421 Some(started) => now.duration_since(started) < Duration::from_secs(max_deferral_secs),
422 }
423}
424
425pub fn postpone_counter(t: &BreakTimers, kind: BreakKind) -> u32 {
428 match kind {
429 BreakKind::Micro => t.micro_postpone_count,
430 BreakKind::Long => t.long_postpone_count,
431 BreakKind::Sleep => 0,
432 }
433}
434
435pub fn reset_postpone_counter(t: &mut BreakTimers, kind: BreakKind) {
438 match kind {
439 BreakKind::Micro => t.micro_postpone_count = 0,
440 BreakKind::Long => t.long_postpone_count = 0,
441 BreakKind::Sleep => {}
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448 use std::time::Duration;
449
450 #[test]
451 fn in_window_normal() {
452 assert!(in_window(540, 540, 1080));
453 assert!(in_window(800, 540, 1080));
454 assert!(in_window(1079, 540, 1080));
455 assert!(!in_window(539, 540, 1080));
456 assert!(!in_window(1080, 540, 1080));
457 assert!(!in_window(0, 540, 1080));
458 }
459
460 #[test]
461 fn in_window_wraps_midnight() {
462 assert!(in_window(1320, 1320, 360));
463 assert!(in_window(1439, 1320, 360));
464 assert!(in_window(0, 1320, 360));
465 assert!(in_window(359, 1320, 360));
466 assert!(!in_window(360, 1320, 360));
467 assert!(!in_window(720, 1320, 360));
468 }
469
470 #[test]
471 fn in_window_empty_when_equal() {
472 assert!(!in_window(0, 720, 720));
473 assert!(!in_window(720, 720, 720));
474 assert!(!in_window(1000, 720, 720));
475 }
476
477 #[test]
478 fn current_minutes_in_range() {
479 let m = current_minutes();
480 assert!(m < 24 * 60);
481 }
482
483 #[test]
484 fn current_weekday_in_range() {
485 assert!(current_weekday() < 7);
486 }
487
488 const ALL_DAYS: u8 = 0b111_1111;
489
490 #[test]
491 fn work_window_active_all_days_matches_in_window() {
492 for today in 0..7 {
495 assert_eq!(
496 work_window_active(800, 540, 1080, today, ALL_DAYS),
497 in_window(800, 540, 1080),
498 );
499 assert_eq!(
500 work_window_active(200, 540, 1080, today, ALL_DAYS),
501 in_window(200, 540, 1080),
502 );
503 }
504 }
505
506 #[test]
507 fn work_window_active_gates_on_enabled_day() {
508 let weekdays = 0b001_1111;
510 assert!(work_window_active(600, 540, 1080, 2, weekdays));
512 assert!(!work_window_active(600, 540, 1080, 5, weekdays));
514 assert!(!work_window_active(1200, 540, 1080, 2, weekdays));
516 }
517
518 #[test]
519 fn work_window_active_empty_mask_is_never_active() {
520 for today in 0..7 {
521 assert!(!work_window_active(600, 540, 1080, today, 0));
522 }
523 }
524
525 #[test]
526 fn work_window_active_wrap_gates_morning_on_starting_day() {
527 let friday = 1 << 4;
529 assert!(work_window_active(1380, 1320, 360, 4, friday));
531 assert!(work_window_active(120, 1320, 360, 5, friday));
534 assert!(!work_window_active(120, 1320, 360, 4, friday));
537 assert!(!work_window_active(1380, 1320, 360, 5, friday));
539 }
540
541 #[test]
542 fn work_window_active_wrap_sunday_to_monday_morning() {
543 let sunday = 1 << 6;
546 assert!(work_window_active(30, 1380, 60, 0, sunday));
547 assert!(!work_window_active(30, 1380, 60, 6, sunday));
549 }
550
551 #[test]
552 fn parse_hhmm_valid_two_digit_hour() {
553 assert_eq!(parse_hhmm("00:00"), Some(0));
554 assert_eq!(parse_hhmm("09:15"), Some(555));
555 assert_eq!(parse_hhmm("12:30"), Some(12 * 60 + 30));
556 assert_eq!(parse_hhmm("23:59"), Some(23 * 60 + 59));
557 }
558
559 #[test]
560 fn parse_hhmm_single_digit_hour() {
561 assert_eq!(parse_hhmm("8:05"), Some(8 * 60 + 5));
562 assert_eq!(parse_hhmm("9:00"), Some(9 * 60));
563 }
564
565 #[test]
566 fn parse_hhmm_trims_whitespace() {
567 assert_eq!(parse_hhmm(" 12:30 "), Some(12 * 60 + 30));
568 }
569
570 #[test]
571 fn parse_hhmm_rejects_out_of_range() {
572 assert_eq!(parse_hhmm("24:00"), None);
573 assert_eq!(parse_hhmm("99:99"), None);
574 assert_eq!(parse_hhmm("12:60"), None);
575 assert_eq!(parse_hhmm("25:30"), None);
576 }
577
578 #[test]
579 fn parse_hhmm_rejects_garbage() {
580 assert_eq!(parse_hhmm(""), None);
581 assert_eq!(parse_hhmm("abc"), None);
582 assert_eq!(parse_hhmm("12:3"), None);
583 assert_eq!(parse_hhmm(":30"), None);
584 assert_eq!(parse_hhmm("12:"), None);
585 assert_eq!(parse_hhmm("12-30"), None);
586 assert_eq!(parse_hhmm("12:30:00"), None);
587 }
588
589 #[test]
590 fn should_fire_fixed_now_first_fire() {
591 assert!(should_fire_fixed_now("2026-03-08", 750, None));
592 }
593
594 #[test]
595 fn should_fire_fixed_now_same_day_same_minute_dedupes() {
596 let last = ("2026-03-08".to_string(), 750u32);
597 assert!(!should_fire_fixed_now("2026-03-08", 750, Some(&last)));
598 }
599
600 #[test]
601 fn should_fire_fixed_now_same_day_different_minute_refires() {
602 let last = ("2026-03-08".to_string(), 750u32);
603 assert!(should_fire_fixed_now("2026-03-08", 751, Some(&last)));
604 assert!(should_fire_fixed_now("2026-03-08", 1020, Some(&last)));
605 }
606
607 #[test]
608 fn should_fire_fixed_now_new_day_refires_same_minute() {
609 let last = ("2026-03-08".to_string(), 750u32);
612 assert!(should_fire_fixed_now("2026-03-09", 750, Some(&last)));
613 }
614
615 #[test]
616 fn should_fire_fixed_now_dst_fall_back_does_not_double_fire() {
617 let last = ("2026-11-01".to_string(), 90u32); assert!(!should_fire_fixed_now("2026-11-01", 90, Some(&last)));
624 }
625
626 #[test]
627 fn should_fire_fixed_now_dst_spring_forward_does_not_resurrect_skipped_minute() {
628 let last = ("2026-03-08".to_string(), 150u32); assert!(should_fire_fixed_now("2026-03-09", 180, Some(&last)));
636 }
637
638 #[test]
639 fn fixed_dedupe_state_clears_on_break_timers_new() {
640 let t = BreakTimers::new();
641 assert!(t.last_micro_fixed_fire.is_none());
642 assert!(t.last_long_fixed_fire.is_none());
643 }
644
645 #[test]
646 fn typing_defer_disabled_returns_false() {
647 let now = Instant::now();
648 assert!(!should_defer_for_typing(false, Some(0), 10, None, 60, now));
649 }
650
651 #[test]
652 fn typing_defer_zero_grace_returns_false() {
653 let now = Instant::now();
654 assert!(!should_defer_for_typing(true, Some(0), 0, None, 60, now));
655 }
656
657 #[test]
658 fn typing_defer_idle_unavailable_does_not_defer() {
659 let now = Instant::now();
662 assert!(!should_defer_for_typing(true, None, 10, None, 60, now));
663 let started = Instant::now();
665 assert!(!should_defer_for_typing(
666 true,
667 None,
668 10,
669 Some(started),
670 60,
671 started + Duration::from_secs(5)
672 ));
673 }
674
675 #[test]
676 fn typing_defer_when_actively_typing_first_tick() {
677 let now = Instant::now();
678 assert!(should_defer_for_typing(true, Some(1), 10, None, 60, now));
679 }
680
681 #[test]
682 fn typing_defer_idle_above_grace_does_not_defer() {
683 let now = Instant::now();
684 assert!(!should_defer_for_typing(true, Some(10), 10, None, 60, now));
685 assert!(!should_defer_for_typing(true, Some(30), 10, None, 60, now));
686 }
687
688 #[test]
689 fn typing_defer_within_cap_keeps_deferring() {
690 let started = Instant::now();
694 let now = started + Duration::from_secs(30);
695 assert!(should_defer_for_typing(
696 true,
697 Some(1),
698 10,
699 Some(started),
700 60,
701 now
702 ));
703 }
704
705 #[test]
706 fn typing_defer_cap_reached_fires_anyway() {
707 let started = Instant::now();
708 let now = started + Duration::from_secs(60);
709 assert!(!should_defer_for_typing(
710 true,
711 Some(1),
712 10,
713 Some(started),
714 60,
715 now
716 ));
717 let older = Instant::now();
718 let now_later = older + Duration::from_secs(120);
719 assert!(!should_defer_for_typing(
720 true,
721 Some(1),
722 10,
723 Some(older),
724 60,
725 now_later
726 ));
727 }
728
729 #[test]
730 fn postpone_counter_reads_per_kind() {
731 let mut t = BreakTimers::new();
732 t.micro_postpone_count = 2;
733 t.long_postpone_count = 5;
734 assert_eq!(postpone_counter(&t, BreakKind::Micro), 2);
735 assert_eq!(postpone_counter(&t, BreakKind::Long), 5);
736 assert_eq!(postpone_counter(&t, BreakKind::Sleep), 0);
737 }
738
739 #[test]
740 fn reset_postpone_counter_only_clears_target_kind() {
741 let mut t = BreakTimers::new();
742 t.micro_postpone_count = 3;
743 t.long_postpone_count = 4;
744 reset_postpone_counter(&mut t, BreakKind::Micro);
745 assert_eq!(t.micro_postpone_count, 0);
746 assert_eq!(t.long_postpone_count, 4);
747 reset_postpone_counter(&mut t, BreakKind::Long);
748 assert_eq!(t.long_postpone_count, 0);
749 }
750
751 #[test]
752 fn clear_last_break_returns_whether_cleared() {
753 let mut t = BreakTimers::new();
754 assert!(!clear_last_break(&mut t));
755 t.last_skipped_or_postponed = Some((BreakKind::Long, Instant::now()));
756 assert!(clear_last_break(&mut t));
757 assert!(t.last_skipped_or_postponed.is_none());
758 assert!(!clear_last_break(&mut t));
759 }
760
761 #[test]
762 fn reset_timers_keep_sleep_preserves_last_sleep_and_active_break() {
763 let mut t = BreakTimers::new();
764 let sleep_at = Instant::now();
765 t.last_sleep = Some(sleep_at);
766 t.active_break = Some(BreakKind::Long);
767 t.micro_warned = true;
768 t.long_warned = true;
769 t.micro_postpone_count = 2;
770 t.long_postpone_count = 3;
771 t.micro_deferred_since = Some(Instant::now());
772 t.long_deferred_since = Some(Instant::now());
773
774 reset_timers_keep_sleep(&mut t);
775
776 assert_eq!(t.last_sleep, Some(sleep_at));
777 assert_eq!(t.active_break, Some(BreakKind::Long));
778 assert!(!t.micro_warned);
779 assert!(!t.long_warned);
780 assert_eq!(t.micro_postpone_count, 0);
781 assert_eq!(t.long_postpone_count, 0);
782 assert!(t.micro_deferred_since.is_none());
783 assert!(t.long_deferred_since.is_none());
784 }
785
786 #[test]
787 fn reset_timers_keep_sleep_clears_with_no_sleep() {
788 let mut t = BreakTimers::new();
789 assert!(t.last_sleep.is_none());
790 t.micro_warned = true;
791 reset_timers_keep_sleep(&mut t);
792 assert!(t.last_sleep.is_none());
793 assert!(!t.micro_warned);
794 }
795
796 #[test]
797 fn reanchor_on_resume_moves_both_interval_clocks_to_now() {
798 let paused_at = Instant::now();
802 let mut t = BreakTimers::new();
803 t.last_micro = paused_at;
804 t.last_long = paused_at;
805 let resumed_at = paused_at + Duration::from_secs(3_600);
806
807 reanchor_intervals_on_resume(&mut t, resumed_at);
808
809 assert_eq!(t.last_micro, resumed_at);
810 assert_eq!(t.last_long, resumed_at);
811 assert!(!interval_break_due(
812 true,
813 true,
814 t.last_micro,
815 1_200,
816 false,
817 resumed_at
818 ));
819 assert!(!interval_break_due(
820 true,
821 true,
822 t.last_long,
823 1_800,
824 false,
825 resumed_at
826 ));
827 }
828
829 #[test]
830 fn reanchor_on_resume_next_due_is_now_plus_interval() {
831 let resumed_at = Instant::now();
832 let mut t = BreakTimers::new();
833 reanchor_intervals_on_resume(&mut t, resumed_at);
834 let interval = 1_200u64;
835 assert!(!interval_break_due(
836 true,
837 true,
838 t.last_micro,
839 interval,
840 false,
841 resumed_at + Duration::from_secs(interval - 1)
842 ));
843 assert!(interval_break_due(
844 true,
845 true,
846 t.last_micro,
847 interval,
848 false,
849 resumed_at + Duration::from_secs(interval)
850 ));
851 }
852
853 #[test]
854 fn reanchor_on_resume_clears_warn_and_deferral_and_postpone() {
855 let mut t = BreakTimers::new();
856 t.micro_warned = true;
857 t.long_warned = true;
858 t.micro_deferred_since = Some(Instant::now());
859 t.long_deferred_since = Some(Instant::now());
860 t.micro_postpone_count = 4;
861 t.long_postpone_count = 7;
862
863 reanchor_intervals_on_resume(&mut t, Instant::now());
864
865 assert!(!t.micro_warned);
866 assert!(!t.long_warned);
867 assert!(t.micro_deferred_since.is_none());
868 assert!(t.long_deferred_since.is_none());
869 assert_eq!(t.micro_postpone_count, 0);
870 assert_eq!(t.long_postpone_count, 0);
871 }
872
873 #[test]
874 fn reanchor_on_resume_preserves_sleep_and_fixed_time_state() {
875 let sleep_at = Instant::now();
879 let mut t = BreakTimers::new();
880 t.last_sleep = Some(sleep_at);
881 t.active_break = Some(BreakKind::Sleep);
882 t.last_micro_fixed_fire = Some(("2026-06-05".into(), 540));
883 t.last_long_fixed_fire = Some(("2026-06-05".into(), 600));
884
885 reanchor_intervals_on_resume(&mut t, Instant::now());
886
887 assert_eq!(t.last_sleep, Some(sleep_at));
888 assert_eq!(t.active_break, Some(BreakKind::Sleep));
889 assert_eq!(t.last_micro_fixed_fire, Some(("2026-06-05".into(), 540)));
890 assert_eq!(t.last_long_fixed_fire, Some(("2026-06-05".into(), 600)));
891 }
892
893 #[test]
901 fn interval_due_fires_when_interval_elapsed() {
902 let last = Instant::now();
903 let now = last + Duration::from_secs(1200);
904 assert!(interval_break_due(true, true, last, 1200, false, now));
905 }
906
907 #[test]
908 fn interval_due_does_not_fire_before_interval() {
909 let last = Instant::now();
910 let now = last + Duration::from_secs(1199);
911 assert!(!interval_break_due(true, true, last, 1200, false, now));
912 }
913
914 #[test]
915 fn interval_due_respects_enabled_flag() {
916 let last = Instant::now();
917 let now = last + Duration::from_secs(2000);
918 assert!(!interval_break_due(false, true, last, 1200, false, now));
919 }
920
921 #[test]
922 fn interval_due_respects_mode_flag() {
923 let last = Instant::now();
927 let now = last + Duration::from_secs(2000);
928 assert!(!interval_break_due(true, false, last, 1200, false, now));
929 }
930
931 #[test]
932 fn interval_due_respects_idle_suppression() {
933 let last = Instant::now();
934 let now = last + Duration::from_secs(2000);
935 assert!(!interval_break_due(true, true, last, 1200, true, now));
936 }
937
938 #[test]
939 fn interval_due_handles_clock_skew_safely() {
940 let now = Instant::now();
943 let future = now + Duration::from_secs(60);
944 assert!(!interval_break_due(true, true, future, 30, false, now));
945 }
946
947 fn warn_gate() -> PrebreakGate {
955 PrebreakGate {
956 enabled: true,
957 mode_includes_interval: true,
958 already_warned: false,
959 idle_suppressed: false,
960 }
961 }
962
963 #[test]
964 fn prebreak_warn_fires_inside_lead_window() {
965 let last = Instant::now();
967 let now = last + Duration::from_secs(1150);
968 assert!(prebreak_warn_due(warn_gate(), last, 1200, 60, now));
969 }
970
971 #[test]
972 fn prebreak_warn_does_not_fire_before_lead_window() {
973 let last = Instant::now();
975 let now = last + Duration::from_secs(1100);
976 assert!(!prebreak_warn_due(warn_gate(), last, 1200, 60, now));
977 }
978
979 #[test]
980 fn prebreak_warn_does_not_fire_after_break_due() {
981 let last = Instant::now();
984 let now = last + Duration::from_secs(1200);
985 assert!(!prebreak_warn_due(warn_gate(), last, 1200, 60, now));
986 let way_late = Instant::now();
987 let later_now = way_late + Duration::from_secs(1250);
988 assert!(!prebreak_warn_due(
989 warn_gate(),
990 way_late,
991 1200,
992 60,
993 later_now
994 ));
995 }
996
997 #[test]
998 fn prebreak_warn_dedupes_via_already_warned() {
999 let last = Instant::now();
1000 let now = last + Duration::from_secs(1150);
1001 assert!(!prebreak_warn_due(
1002 PrebreakGate {
1003 already_warned: true,
1004 ..warn_gate()
1005 },
1006 last,
1007 1200,
1008 60,
1009 now,
1010 ));
1011 }
1012
1013 #[test]
1014 fn prebreak_warn_skips_when_disabled_or_idle() {
1015 let last = Instant::now();
1016 let now = last + Duration::from_secs(1150);
1017 assert!(!prebreak_warn_due(
1018 PrebreakGate {
1019 enabled: false,
1020 ..warn_gate()
1021 },
1022 last,
1023 1200,
1024 60,
1025 now,
1026 ));
1027 assert!(!prebreak_warn_due(
1028 PrebreakGate {
1029 mode_includes_interval: false,
1030 ..warn_gate()
1031 },
1032 last,
1033 1200,
1034 60,
1035 now,
1036 ));
1037 assert!(!prebreak_warn_due(
1038 PrebreakGate {
1039 idle_suppressed: true,
1040 ..warn_gate()
1041 },
1042 last,
1043 1200,
1044 60,
1045 now,
1046 ));
1047 }
1048
1049 #[test]
1050 fn prebreak_warn_handles_lead_larger_than_interval() {
1051 let last = Instant::now();
1055 let now = last + Duration::from_secs(10);
1056 assert!(prebreak_warn_due(warn_gate(), last, 60, 600, now));
1057 }
1058
1059 fn simulate_warn_ticks(interval_secs: u64, lead_secs: u64, cycles: u64) -> Vec<u64> {
1068 let mut t = BreakTimers::new();
1069 let base = Instant::now();
1070 let mut last_anchor = 0u64;
1071 let mut fired_at = Vec::new();
1072 let total = interval_secs * cycles;
1073 for sec in 1..=total {
1074 let now = base + Duration::from_secs(sec);
1075 let gate = PrebreakGate {
1076 enabled: true,
1077 mode_includes_interval: true,
1078 already_warned: t.micro_warned,
1079 idle_suppressed: false,
1080 };
1081 if prebreak_warn_due(gate, t.last_micro, interval_secs, lead_secs, now) {
1082 fired_at.push(sec - last_anchor);
1083 t.micro_warned = true;
1084 }
1085 if interval_break_due(true, true, t.last_micro, interval_secs, false, now) {
1086 record_scheduled_fire(
1087 &mut t,
1088 BreakKind::Micro,
1089 BreakDelivery::Notification,
1090 now,
1091 None,
1092 );
1093 last_anchor = sec;
1094 }
1095 }
1096 fired_at
1097 }
1098
1099 #[test]
1100 fn prebreak_warn_fires_once_per_cycle_and_rearms() {
1101 let fired = simulate_warn_ticks(900, 30, 3);
1102 assert_eq!(
1103 fired,
1104 vec![870, 870, 870],
1105 "warn must fire exactly once per cycle at the lead boundary and re-arm each cycle"
1106 );
1107 }
1108
1109 #[test]
1110 fn prebreak_warn_survives_narrow_two_tick_window_each_cycle() {
1111 let fired = simulate_warn_ticks(5, 2, 4);
1112 assert_eq!(
1113 fired,
1114 vec![3, 3, 3, 3],
1115 "a 2-tick-wide warn band [3,5) must still fire once and re-arm every cycle"
1116 );
1117 }
1118
1119 fn window_2206() -> BedtimeWindow {
1125 BedtimeWindow {
1126 enabled: true,
1127 start_min: 22 * 60,
1128 end_min: 6 * 60,
1129 interval_secs: 1800,
1130 }
1131 }
1132
1133 fn window_2209() -> BedtimeWindow {
1135 BedtimeWindow {
1136 enabled: true,
1137 start_min: 22 * 60,
1138 end_min: 9 * 60,
1139 interval_secs: 300,
1140 }
1141 }
1142
1143 #[test]
1144 fn bedtime_not_in_window_returns_not_in_window() {
1145 let now = Instant::now();
1146 assert_eq!(
1148 decide_bedtime(window_2206(), 12 * 60, None, now, false),
1149 BedtimeAction::NotInWindow
1150 );
1151 }
1152
1153 #[test]
1154 fn bedtime_disabled_returns_not_in_window_even_in_range() {
1155 let now = Instant::now();
1156 assert_eq!(
1157 decide_bedtime(
1158 BedtimeWindow {
1159 enabled: false,
1160 ..window_2206()
1161 },
1162 23 * 60,
1163 None,
1164 now,
1165 false,
1166 ),
1167 BedtimeAction::NotInWindow
1168 );
1169 }
1170
1171 #[test]
1172 fn bedtime_first_tick_of_window_fires() {
1173 let now = Instant::now();
1174 assert_eq!(
1175 decide_bedtime(window_2206(), 23 * 60, None, now, false),
1176 BedtimeAction::Fire
1177 );
1178 }
1179
1180 #[test]
1181 fn bedtime_re_fires_after_interval() {
1182 let last = Instant::now();
1183 let now = last + Duration::from_secs(1800);
1184 assert_eq!(
1185 decide_bedtime(window_2206(), 23 * 60, Some(last), now, false),
1186 BedtimeAction::Fire
1187 );
1188 }
1189
1190 #[test]
1191 fn bedtime_resets_only_inside_interval() {
1192 let last = Instant::now();
1194 let now = last + Duration::from_secs(900);
1195 assert_eq!(
1196 decide_bedtime(window_2206(), 23 * 60, Some(last), now, false),
1197 BedtimeAction::ResetTimersOnly
1198 );
1199 }
1200
1201 #[test]
1202 fn bedtime_window_handles_midnight_wrap() {
1203 let now = Instant::now();
1204 assert_eq!(
1206 decide_bedtime(window_2206(), 2 * 60, None, now, false),
1207 BedtimeAction::Fire
1208 );
1209 }
1210
1211 #[test]
1212 fn bedtime_handles_clock_skew_safely() {
1213 let now = Instant::now();
1216 let future = now + Duration::from_secs(60);
1217 assert_eq!(
1218 decide_bedtime(window_2206(), 23 * 60, Some(future), now, false),
1219 BedtimeAction::ResetTimersOnly
1220 );
1221 }
1222
1223 #[test]
1224 fn bedtime_resume_from_suspend_does_not_refire_after_prior_prompt() {
1225 let last = Instant::now();
1230 let now = last + Duration::from_secs(8 * 3600);
1231 assert_eq!(
1232 decide_bedtime(window_2209(), 8 * 60, Some(last), now, true),
1233 BedtimeAction::ResetTimersOnly
1234 );
1235 assert_eq!(
1238 decide_bedtime(window_2209(), 8 * 60, Some(last), now, false),
1239 BedtimeAction::Fire
1240 );
1241 }
1242
1243 #[test]
1244 fn bedtime_resume_into_first_entry_still_fires() {
1245 let now = Instant::now();
1249 assert_eq!(
1250 decide_bedtime(window_2209(), 23 * 60, None, now, true),
1251 BedtimeAction::Fire
1252 );
1253 }
1254
1255 #[test]
1256 fn bedtime_resume_outside_window_is_noop() {
1257 let now = Instant::now();
1259 assert_eq!(
1260 decide_bedtime(window_2209(), 12 * 60, Some(now), now, true),
1261 BedtimeAction::NotInWindow
1262 );
1263 }
1264
1265 fn dirty_timers() -> BreakTimers {
1266 let mut t = BreakTimers::new();
1267 t.micro_warned = true;
1268 t.long_warned = true;
1269 t.micro_deferred_since = Some(Instant::now());
1270 t.long_deferred_since = Some(Instant::now());
1271 t
1272 }
1273
1274 #[test]
1275 fn record_scheduled_fire_long_resets_both_clocks_and_records_fixed_key() {
1276 let mut t = dirty_timers();
1277 let now = Instant::now();
1278 record_scheduled_fire(
1279 &mut t,
1280 BreakKind::Long,
1281 BreakDelivery::Overlay,
1282 now,
1283 Some(("2026-06-02".into(), 540)),
1284 );
1285 assert_eq!(t.last_long, now);
1286 assert_eq!(t.last_micro, now);
1287 assert!(!t.long_warned);
1288 assert!(!t.micro_warned);
1289 assert_eq!(t.long_deferred_since, None);
1290 assert_eq!(t.micro_deferred_since, None);
1291 assert_eq!(t.active_break, Some(BreakKind::Long));
1292 assert_eq!(t.last_long_fixed_fire, Some(("2026-06-02".into(), 540)));
1293 assert_eq!(t.last_micro_fixed_fire, None);
1294 }
1295
1296 #[test]
1297 fn record_scheduled_fire_micro_touches_only_micro_fields() {
1298 let mut t = dirty_timers();
1299 let before_long = t.last_long;
1300 let now = Instant::now();
1301 record_scheduled_fire(
1302 &mut t,
1303 BreakKind::Micro,
1304 BreakDelivery::Overlay,
1305 now,
1306 Some(("2026-06-02".into(), 600)),
1307 );
1308 assert_eq!(t.last_micro, now);
1309 assert_eq!(t.last_long, before_long);
1310 assert!(!t.micro_warned);
1311 assert!(
1312 t.long_warned,
1313 "micro fire must not clear the long warn flag"
1314 );
1315 assert_eq!(t.micro_deferred_since, None);
1316 assert!(t.long_deferred_since.is_some());
1317 assert_eq!(t.active_break, Some(BreakKind::Micro));
1318 assert_eq!(t.last_micro_fixed_fire, Some(("2026-06-02".into(), 600)));
1319 assert_eq!(t.last_long_fixed_fire, None);
1320 }
1321
1322 #[test]
1323 fn record_scheduled_fire_interval_leaves_fixed_keys_untouched() {
1324 let mut t = BreakTimers::new();
1325 t.last_long_fixed_fire = Some(("2026-06-01".into(), 100));
1326 record_scheduled_fire(
1327 &mut t,
1328 BreakKind::Long,
1329 BreakDelivery::Overlay,
1330 Instant::now(),
1331 None,
1332 );
1333 assert_eq!(t.last_long_fixed_fire, Some(("2026-06-01".into(), 100)));
1334 }
1335
1336 #[test]
1337 fn record_scheduled_fire_notification_does_not_stash_active_break() {
1338 let mut t = BreakTimers::new();
1339 record_scheduled_fire(
1340 &mut t,
1341 BreakKind::Micro,
1342 BreakDelivery::Notification,
1343 Instant::now(),
1344 None,
1345 );
1346 assert_eq!(t.active_break, None);
1347 }
1348
1349 #[test]
1350 fn record_scheduled_fire_windowed_stashes_active_break() {
1351 let mut t = BreakTimers::new();
1352 record_scheduled_fire(
1353 &mut t,
1354 BreakKind::Long,
1355 BreakDelivery::Windowed,
1356 Instant::now(),
1357 None,
1358 );
1359 assert_eq!(t.active_break, Some(BreakKind::Long));
1360 }
1361
1362 #[test]
1363 fn record_scheduled_fire_sleep_is_a_noop_for_per_kind_state() {
1364 let mut t = dirty_timers();
1365 let before_micro = t.last_micro;
1366 let before_long = t.last_long;
1367 record_scheduled_fire(
1368 &mut t,
1369 BreakKind::Sleep,
1370 BreakDelivery::Notification,
1371 Instant::now(),
1372 None,
1373 );
1374 assert_eq!(t.last_micro, before_micro);
1375 assert_eq!(t.last_long, before_long);
1376 assert!(t.micro_warned);
1377 assert!(t.long_warned);
1378 assert_eq!(t.active_break, None);
1379 }
1380}