Skip to main content

entracte_lib/scheduler/
timers.rs

1use std::time::{Duration, Instant};
2
3use chrono::{Local, Timelike};
4
5use super::types::BreakKind;
6
7/// All of the scheduler's per-tick mutable timing state.
8///
9/// Held behind a `tokio::Mutex` inside `Scheduler`. Every field tracks
10/// either when something last happened (`last_*`) or where we are in
11/// a per-kind state machine (warned, deferred-since, postpone counter).
12#[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    /// `(local-date, minute-of-day)` of the most recent fixed-time micro
26    /// fire. Keyed by date so the dedupe survives DST transitions: a
27    /// "fall back" 02:00 → 01:00 reuses the same minute on the same day,
28    /// and "spring forward" never strands the dedupe pointing at a minute
29    /// that no longer exists on the wall clock.
30    pub last_micro_fixed_fire: Option<(String, u32)>,
31    pub last_long_fixed_fire: Option<(String, u32)>,
32}
33
34impl BreakTimers {
35    /// Fresh timers with both interval clocks anchored at `Instant::now()`
36    /// and every flag / counter cleared. Used at scheduler boot.
37    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
57/// Reset the micro / long timers and clear deferral / postpone state
58/// without disturbing `last_sleep` or `active_break`. Called when the
59/// active profile switches: a new profile gets fresh intervals but we
60/// don't want to re-fire a sleep prompt that's already shown today.
61pub fn reset_timers_keep_sleep(t: &mut BreakTimers) {
62    let now = Instant::now();
63    t.last_micro = now;
64    t.last_long = now;
65    t.micro_warned = false;
66    t.long_warned = false;
67    t.micro_deferred_since = None;
68    t.long_deferred_since = None;
69    t.micro_postpone_count = 0;
70    t.long_postpone_count = 0;
71}
72
73/// Clear the "resume last skipped break" slot. Returns `true` iff a
74/// stored break was actually cleared (used to decide whether to emit
75/// the `last_break:changed` event).
76pub fn clear_last_break(t: &mut BreakTimers) -> bool {
77    if t.last_skipped_or_postponed.is_some() {
78        t.last_skipped_or_postponed = None;
79        true
80    } else {
81        false
82    }
83}
84
85/// Minutes since local midnight (0..1440). The unit used everywhere
86/// the scheduler reasons about time-of-day windows (work hours,
87/// bedtime window, fixed-time break list).
88pub fn current_minutes() -> u32 {
89    let now = Local::now();
90    now.hour() * 60 + now.minute()
91}
92
93/// ISO-8601 date in local time (`"YYYY-MM-DD"`). Used to detect
94/// midnight rollovers for screen-time / fixed-time dedupe state.
95pub fn local_today_string() -> String {
96    Local::now().date_naive().format("%Y-%m-%d").to_string()
97}
98
99/// Parse `"HH:MM"` (or `"H:MM"`) into minutes since midnight.
100/// Returns `None` on anything out of range or unparseable — used to
101/// filter the user's fixed-time list without spilling errors.
102pub fn parse_hhmm(s: &str) -> Option<u32> {
103    let trimmed = s.trim();
104    let (h_str, m_str) = trimmed.split_once(':')?;
105    if h_str.is_empty() || m_str.len() != 2 {
106        return None;
107    }
108    let h: u32 = h_str.parse().ok()?;
109    let m: u32 = m_str.parse().ok()?;
110    if h >= 24 || m >= 60 {
111        return None;
112    }
113    Some(h * 60 + m)
114}
115
116/// Dedupe gate for fixed-time fires: `true` unless we already fired
117/// this exact `(date, minute)` slot. Prevents the 1Hz tick from firing
118/// the same fixed slot up to 60 times, and (because the key includes
119/// the local date) stays correct across DST: `02:00` on a "fall back"
120/// day fires once even though the wall clock visits it twice, and
121/// `02:30` on a "spring forward" day simply never matches.
122pub fn should_fire_fixed_now(
123    today: &str,
124    current_min: u32,
125    last_fire: Option<&(String, u32)>,
126) -> bool {
127    match last_fire {
128        Some((day, prev_min)) => day != today || *prev_min != current_min,
129        None => true,
130    }
131}
132
133/// True iff `now` (minutes since midnight) falls inside `[start, end)`,
134/// with wrap-around: a window like `22:00`–`06:00` correctly straddles
135/// midnight. `start == end` is treated as an empty window.
136pub fn in_window(now: u32, start: u32, end: u32) -> bool {
137    if start == end {
138        return false;
139    }
140    if start < end {
141        now >= start && now < end
142    } else {
143        now >= start || now < end
144    }
145}
146
147/// True iff an interval-mode break of this kind is due to fire now.
148///
149/// All inputs are explicit so callers can drive it with a synthetic
150/// `Instant` in tests. `now.saturating_duration_since(last_fire)` mirrors
151/// the production check `last_fire.elapsed()` with a frozen clock.
152///
153/// `mode_includes_interval` is the de-stringified equivalent of the
154/// settings's `*_schedule_mode` ∈ {`"interval"`, `"both"`} — done at the
155/// call site so this stays clock-agnostic.
156pub fn interval_break_due(
157    enabled: bool,
158    mode_includes_interval: bool,
159    last_fire: Instant,
160    interval_secs: u64,
161    idle_suppressed: bool,
162    now: Instant,
163) -> bool {
164    enabled
165        && mode_includes_interval
166        && !idle_suppressed
167        && now.saturating_duration_since(last_fire) >= Duration::from_secs(interval_secs)
168}
169
170/// True iff the pre-break notification for this kind should fire now —
171/// i.e. we're inside the lead window before a due interval break, and
172/// we haven't already shown the notification for this cycle.
173///
174/// Pure analogue of the inline check in `run_loop`. The arg list is
175/// wide because the function is deliberately decoupled from `Scheduler`
176/// state so it can be driven by tests with synthetic instants — a
177/// wrapper struct would just shuffle the same fields around.
178#[allow(clippy::too_many_arguments)]
179pub fn prebreak_warn_due(
180    enabled: bool,
181    mode_includes_interval: bool,
182    last_fire: Instant,
183    interval_secs: u64,
184    lead_secs: u64,
185    already_warned: bool,
186    idle_suppressed: bool,
187    now: Instant,
188) -> bool {
189    if !enabled || !mode_includes_interval || idle_suppressed || already_warned {
190        return false;
191    }
192    let interval = Duration::from_secs(interval_secs);
193    let lead = Duration::from_secs(lead_secs);
194    let warn_at = interval.saturating_sub(lead);
195    let elapsed = now.saturating_duration_since(last_fire);
196    elapsed >= warn_at && elapsed < interval
197}
198
199/// Decision returned by `decide_bedtime` — fully captures what the tick
200/// should do with the bedtime window. The caller still performs the
201/// side effects (overlay, hooks, logging, timer mutation).
202#[derive(Debug, PartialEq, Eq, Clone, Copy)]
203pub enum BedtimeAction {
204    /// In the bedtime window AND it's time to (re)show the prompt.
205    Fire,
206    /// In the bedtime window but the per-window interval hasn't elapsed
207    /// since the last prompt — only reset the micro/long anchors so
208    /// they don't pile up while the user is winding down.
209    ResetTimersOnly,
210    /// Outside the bedtime window — bedtime branch is a no-op this tick.
211    NotInWindow,
212}
213
214/// Pure bedtime decision: combine the time-of-day window, the per-window
215/// interval, and the `last_sleep` anchor into one of three actions.
216///
217/// `last_sleep == None` always fires on the first tick of the window —
218/// the cap only kicks in for re-prompts.
219pub fn decide_bedtime(
220    enabled: bool,
221    now_min: u32,
222    start_min: u32,
223    end_min: u32,
224    interval_secs: u64,
225    last_sleep_fire: Option<Instant>,
226    now: Instant,
227) -> BedtimeAction {
228    if !enabled || !in_window(now_min, start_min, end_min) {
229        return BedtimeAction::NotInWindow;
230    }
231    let should_fire = match last_sleep_fire {
232        None => true,
233        Some(t) => now.saturating_duration_since(t) >= Duration::from_secs(interval_secs),
234    };
235    if should_fire {
236        BedtimeAction::Fire
237    } else {
238        BedtimeAction::ResetTimersOnly
239    }
240}
241
242/// Decide whether a due break should be delayed because the user is
243/// mid-keystroke. Returns `true` while we should keep waiting and
244/// `false` once either the user has paused typing OR the deferral cap
245/// has been reached (so we don't postpone indefinitely).
246///
247/// `deferred_since` is the instant the current defer-streak started,
248/// or `None` if this is the first tick of the streak.
249pub fn should_defer_for_typing(
250    enabled: bool,
251    idle_secs: u64,
252    grace_secs: u64,
253    deferred_since: Option<Instant>,
254    max_deferral_secs: u64,
255    now: Instant,
256) -> bool {
257    if !enabled || grace_secs == 0 {
258        return false;
259    }
260    if idle_secs >= grace_secs {
261        return false;
262    }
263    match deferred_since {
264        None => true,
265        Some(started) => now.duration_since(started) < Duration::from_secs(max_deferral_secs),
266    }
267}
268
269/// How many times the user has postponed the current break of this
270/// kind. `Sleep` always returns 0 (sleep prompts don't escalate).
271pub fn postpone_counter(t: &BreakTimers, kind: BreakKind) -> u32 {
272    match kind {
273        BreakKind::Micro => t.micro_postpone_count,
274        BreakKind::Long => t.long_postpone_count,
275        BreakKind::Sleep => 0,
276    }
277}
278
279/// Zero out the postpone counter for this kind. Called when a break
280/// completes successfully — the user gets a fresh budget next time.
281pub fn reset_postpone_counter(t: &mut BreakTimers, kind: BreakKind) {
282    match kind {
283        BreakKind::Micro => t.micro_postpone_count = 0,
284        BreakKind::Long => t.long_postpone_count = 0,
285        BreakKind::Sleep => {}
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::time::Duration;
293
294    #[test]
295    fn in_window_normal() {
296        assert!(in_window(540, 540, 1080));
297        assert!(in_window(800, 540, 1080));
298        assert!(in_window(1079, 540, 1080));
299        assert!(!in_window(539, 540, 1080));
300        assert!(!in_window(1080, 540, 1080));
301        assert!(!in_window(0, 540, 1080));
302    }
303
304    #[test]
305    fn in_window_wraps_midnight() {
306        assert!(in_window(1320, 1320, 360));
307        assert!(in_window(1439, 1320, 360));
308        assert!(in_window(0, 1320, 360));
309        assert!(in_window(359, 1320, 360));
310        assert!(!in_window(360, 1320, 360));
311        assert!(!in_window(720, 1320, 360));
312    }
313
314    #[test]
315    fn in_window_empty_when_equal() {
316        assert!(!in_window(0, 720, 720));
317        assert!(!in_window(720, 720, 720));
318        assert!(!in_window(1000, 720, 720));
319    }
320
321    #[test]
322    fn current_minutes_in_range() {
323        let m = current_minutes();
324        assert!(m < 24 * 60);
325    }
326
327    #[test]
328    fn parse_hhmm_valid_two_digit_hour() {
329        assert_eq!(parse_hhmm("00:00"), Some(0));
330        assert_eq!(parse_hhmm("09:15"), Some(555));
331        assert_eq!(parse_hhmm("12:30"), Some(12 * 60 + 30));
332        assert_eq!(parse_hhmm("23:59"), Some(23 * 60 + 59));
333    }
334
335    #[test]
336    fn parse_hhmm_single_digit_hour() {
337        assert_eq!(parse_hhmm("8:05"), Some(8 * 60 + 5));
338        assert_eq!(parse_hhmm("9:00"), Some(9 * 60));
339    }
340
341    #[test]
342    fn parse_hhmm_trims_whitespace() {
343        assert_eq!(parse_hhmm("  12:30 "), Some(12 * 60 + 30));
344    }
345
346    #[test]
347    fn parse_hhmm_rejects_out_of_range() {
348        assert_eq!(parse_hhmm("24:00"), None);
349        assert_eq!(parse_hhmm("99:99"), None);
350        assert_eq!(parse_hhmm("12:60"), None);
351        assert_eq!(parse_hhmm("25:30"), None);
352    }
353
354    #[test]
355    fn parse_hhmm_rejects_garbage() {
356        assert_eq!(parse_hhmm(""), None);
357        assert_eq!(parse_hhmm("abc"), None);
358        assert_eq!(parse_hhmm("12:3"), None);
359        assert_eq!(parse_hhmm(":30"), None);
360        assert_eq!(parse_hhmm("12:"), None);
361        assert_eq!(parse_hhmm("12-30"), None);
362        assert_eq!(parse_hhmm("12:30:00"), None);
363    }
364
365    #[test]
366    fn should_fire_fixed_now_first_fire() {
367        assert!(should_fire_fixed_now("2026-03-08", 750, None));
368    }
369
370    #[test]
371    fn should_fire_fixed_now_same_day_same_minute_dedupes() {
372        let last = ("2026-03-08".to_string(), 750u32);
373        assert!(!should_fire_fixed_now("2026-03-08", 750, Some(&last)));
374    }
375
376    #[test]
377    fn should_fire_fixed_now_same_day_different_minute_refires() {
378        let last = ("2026-03-08".to_string(), 750u32);
379        assert!(should_fire_fixed_now("2026-03-08", 751, Some(&last)));
380        assert!(should_fire_fixed_now("2026-03-08", 1020, Some(&last)));
381    }
382
383    #[test]
384    fn should_fire_fixed_now_new_day_refires_same_minute() {
385        // Crossing midnight resets the dedupe so the next day's first
386        // hit of the fixed-time slot fires.
387        let last = ("2026-03-08".to_string(), 750u32);
388        assert!(should_fire_fixed_now("2026-03-09", 750, Some(&last)));
389    }
390
391    #[test]
392    fn should_fire_fixed_now_dst_fall_back_does_not_double_fire() {
393        // North-American "fall back": at 02:00 local the clock jumps
394        // back to 01:00 so wall-clock minutes 60..119 are traversed
395        // twice. Same-day dedupe must keep a single fire per (date,
396        // minute) — otherwise every fixed-time slot in that hour would
397        // fire twice on DST end.
398        let last = ("2026-11-01".to_string(), 90u32); // 01:30 first pass
399        assert!(!should_fire_fixed_now("2026-11-01", 90, Some(&last)));
400    }
401
402    #[test]
403    fn should_fire_fixed_now_dst_spring_forward_does_not_resurrect_skipped_minute() {
404        // "Spring forward": 02:00 → 03:00, so minutes 120..179 (02:00–02:59)
405        // are skipped entirely. The minute simply never appears on the
406        // wall clock, so dedupe doesn't need a fix for it — but if a
407        // previous day's fire was at 02:30 the next day at 02:30 (a real
408        // minute on a non-DST day) should still re-fire, which it does
409        // because the date key differs.
410        let last = ("2026-03-08".to_string(), 150u32); // 02:30 the day before DST
411        assert!(should_fire_fixed_now("2026-03-09", 180, Some(&last)));
412    }
413
414    #[test]
415    fn fixed_dedupe_state_clears_on_break_timers_new() {
416        let t = BreakTimers::new();
417        assert!(t.last_micro_fixed_fire.is_none());
418        assert!(t.last_long_fixed_fire.is_none());
419    }
420
421    #[test]
422    fn typing_defer_disabled_returns_false() {
423        let now = Instant::now();
424        assert!(!should_defer_for_typing(false, 0, 10, None, 60, now));
425    }
426
427    #[test]
428    fn typing_defer_zero_grace_returns_false() {
429        let now = Instant::now();
430        assert!(!should_defer_for_typing(true, 0, 0, None, 60, now));
431    }
432
433    #[test]
434    fn typing_defer_when_actively_typing_first_tick() {
435        let now = Instant::now();
436        assert!(should_defer_for_typing(true, 1, 10, None, 60, now));
437    }
438
439    #[test]
440    fn typing_defer_idle_above_grace_does_not_defer() {
441        let now = Instant::now();
442        assert!(!should_defer_for_typing(true, 10, 10, None, 60, now));
443        assert!(!should_defer_for_typing(true, 30, 10, None, 60, now));
444    }
445
446    #[test]
447    fn typing_defer_within_cap_keeps_deferring() {
448        // Anchor at `started` and derive `now = started + 30s`; never
449        // subtract from `Instant::now()` (panics on Windows when the
450        // monotonic clock is younger than the offset).
451        let started = Instant::now();
452        let now = started + Duration::from_secs(30);
453        assert!(should_defer_for_typing(true, 1, 10, Some(started), 60, now));
454    }
455
456    #[test]
457    fn typing_defer_cap_reached_fires_anyway() {
458        let started = Instant::now();
459        let now = started + Duration::from_secs(60);
460        assert!(!should_defer_for_typing(
461            true,
462            1,
463            10,
464            Some(started),
465            60,
466            now
467        ));
468        let older = Instant::now();
469        let now_later = older + Duration::from_secs(120);
470        assert!(!should_defer_for_typing(
471            true,
472            1,
473            10,
474            Some(older),
475            60,
476            now_later
477        ));
478    }
479
480    #[test]
481    fn postpone_counter_reads_per_kind() {
482        let mut t = BreakTimers::new();
483        t.micro_postpone_count = 2;
484        t.long_postpone_count = 5;
485        assert_eq!(postpone_counter(&t, BreakKind::Micro), 2);
486        assert_eq!(postpone_counter(&t, BreakKind::Long), 5);
487        assert_eq!(postpone_counter(&t, BreakKind::Sleep), 0);
488    }
489
490    #[test]
491    fn reset_postpone_counter_only_clears_target_kind() {
492        let mut t = BreakTimers::new();
493        t.micro_postpone_count = 3;
494        t.long_postpone_count = 4;
495        reset_postpone_counter(&mut t, BreakKind::Micro);
496        assert_eq!(t.micro_postpone_count, 0);
497        assert_eq!(t.long_postpone_count, 4);
498        reset_postpone_counter(&mut t, BreakKind::Long);
499        assert_eq!(t.long_postpone_count, 0);
500    }
501
502    #[test]
503    fn clear_last_break_returns_whether_cleared() {
504        let mut t = BreakTimers::new();
505        assert!(!clear_last_break(&mut t));
506        t.last_skipped_or_postponed = Some((BreakKind::Long, Instant::now()));
507        assert!(clear_last_break(&mut t));
508        assert!(t.last_skipped_or_postponed.is_none());
509        assert!(!clear_last_break(&mut t));
510    }
511
512    #[test]
513    fn reset_timers_keep_sleep_preserves_last_sleep_and_active_break() {
514        let mut t = BreakTimers::new();
515        let sleep_at = Instant::now();
516        t.last_sleep = Some(sleep_at);
517        t.active_break = Some(BreakKind::Long);
518        t.micro_warned = true;
519        t.long_warned = true;
520        t.micro_postpone_count = 2;
521        t.long_postpone_count = 3;
522        t.micro_deferred_since = Some(Instant::now());
523        t.long_deferred_since = Some(Instant::now());
524
525        reset_timers_keep_sleep(&mut t);
526
527        assert_eq!(t.last_sleep, Some(sleep_at));
528        assert_eq!(t.active_break, Some(BreakKind::Long));
529        assert!(!t.micro_warned);
530        assert!(!t.long_warned);
531        assert_eq!(t.micro_postpone_count, 0);
532        assert_eq!(t.long_postpone_count, 0);
533        assert!(t.micro_deferred_since.is_none());
534        assert!(t.long_deferred_since.is_none());
535    }
536
537    #[test]
538    fn reset_timers_keep_sleep_clears_with_no_sleep() {
539        let mut t = BreakTimers::new();
540        assert!(t.last_sleep.is_none());
541        t.micro_warned = true;
542        reset_timers_keep_sleep(&mut t);
543        assert!(t.last_sleep.is_none());
544        assert!(!t.micro_warned);
545    }
546
547    // `interval_break_due` — the workhorse decision for "is this break
548    // due to fire on this tick?". Frozen clock here is built by
549    // anchoring at `Instant::now()` and adding offsets to derive "later"
550    // points — never subtracting from `now()`, because on Windows the
551    // monotonic clock can underflow on a fresh runner (`Instant::sub`
552    // panics if the result would be before boot).
553
554    #[test]
555    fn interval_due_fires_when_interval_elapsed() {
556        let last = Instant::now();
557        let now = last + Duration::from_secs(1200);
558        assert!(interval_break_due(true, true, last, 1200, false, now));
559    }
560
561    #[test]
562    fn interval_due_does_not_fire_before_interval() {
563        let last = Instant::now();
564        let now = last + Duration::from_secs(1199);
565        assert!(!interval_break_due(true, true, last, 1200, false, now));
566    }
567
568    #[test]
569    fn interval_due_respects_enabled_flag() {
570        let last = Instant::now();
571        let now = last + Duration::from_secs(2000);
572        assert!(!interval_break_due(false, true, last, 1200, false, now));
573    }
574
575    #[test]
576    fn interval_due_respects_mode_flag() {
577        // mode "fixed" → mode_includes_interval is false → no fire even
578        // though the interval has elapsed. Catches the regression where
579        // a user switched to fixed-only and intervals kept firing.
580        let last = Instant::now();
581        let now = last + Duration::from_secs(2000);
582        assert!(!interval_break_due(true, false, last, 1200, false, now));
583    }
584
585    #[test]
586    fn interval_due_respects_idle_suppression() {
587        let last = Instant::now();
588        let now = last + Duration::from_secs(2000);
589        assert!(!interval_break_due(true, true, last, 1200, true, now));
590    }
591
592    #[test]
593    fn interval_due_handles_clock_skew_safely() {
594        // `last_fire` in the future shouldn't panic — saturating_sub
595        // returns zero, which fails the `>= interval` check.
596        let now = Instant::now();
597        let future = now + Duration::from_secs(60);
598        assert!(!interval_break_due(true, true, future, 30, false, now));
599    }
600
601    // `prebreak_warn_due` — fires once per interval cycle, in a narrow
602    // band before the break itself. The `already_warned` flag is the
603    // dedupe gate.
604
605    #[test]
606    fn prebreak_warn_fires_inside_lead_window() {
607        // 50s before a 1200s break, lead is 60s → in the warn band.
608        let last = Instant::now();
609        let now = last + Duration::from_secs(1150);
610        assert!(prebreak_warn_due(
611            true, true, last, 1200, 60, false, false, now
612        ));
613    }
614
615    #[test]
616    fn prebreak_warn_does_not_fire_before_lead_window() {
617        // 100s before a 1200s break, lead is 60s → outside warn band.
618        let last = Instant::now();
619        let now = last + Duration::from_secs(1100);
620        assert!(!prebreak_warn_due(
621            true, true, last, 1200, 60, false, false, now
622        ));
623    }
624
625    #[test]
626    fn prebreak_warn_does_not_fire_after_break_due() {
627        // Once we've hit the interval the break itself fires — warning
628        // shouldn't re-fire post-interval.
629        let last = Instant::now();
630        let now = last + Duration::from_secs(1200);
631        assert!(!prebreak_warn_due(
632            true, true, last, 1200, 60, false, false, now
633        ));
634        let way_late = Instant::now();
635        let later_now = way_late + Duration::from_secs(1250);
636        assert!(!prebreak_warn_due(
637            true, true, way_late, 1200, 60, false, false, later_now
638        ));
639    }
640
641    #[test]
642    fn prebreak_warn_dedupes_via_already_warned() {
643        let last = Instant::now();
644        let now = last + Duration::from_secs(1150);
645        assert!(!prebreak_warn_due(
646            true, true, last, 1200, 60, true, false, now
647        ));
648    }
649
650    #[test]
651    fn prebreak_warn_skips_when_disabled_or_idle() {
652        let last = Instant::now();
653        let now = last + Duration::from_secs(1150);
654        assert!(!prebreak_warn_due(
655            false, true, last, 1200, 60, false, false, now
656        ));
657        assert!(!prebreak_warn_due(
658            true, false, last, 1200, 60, false, false, now
659        ));
660        assert!(!prebreak_warn_due(
661            true, true, last, 1200, 60, false, true, now
662        ));
663    }
664
665    #[test]
666    fn prebreak_warn_handles_lead_larger_than_interval() {
667        // saturating_sub means warn_at = 0 — the warning fires
668        // immediately after the previous break. Unusual config but must
669        // not panic or warn forever.
670        let last = Instant::now();
671        let now = last + Duration::from_secs(10);
672        assert!(prebreak_warn_due(
673            true, true, last, 60, 600, false, false, now
674        ));
675    }
676
677    // `decide_bedtime` — three-way decision combining window membership
678    // and per-window interval. The first tick of the window always
679    // fires (`None` last_sleep).
680
681    #[test]
682    fn bedtime_not_in_window_returns_not_in_window() {
683        let now = Instant::now();
684        // 12:00, window 22:00–06:00
685        assert_eq!(
686            decide_bedtime(true, 12 * 60, 22 * 60, 6 * 60, 1800, None, now),
687            BedtimeAction::NotInWindow
688        );
689    }
690
691    #[test]
692    fn bedtime_disabled_returns_not_in_window_even_in_range() {
693        let now = Instant::now();
694        assert_eq!(
695            decide_bedtime(false, 23 * 60, 22 * 60, 6 * 60, 1800, None, now),
696            BedtimeAction::NotInWindow
697        );
698    }
699
700    #[test]
701    fn bedtime_first_tick_of_window_fires() {
702        let now = Instant::now();
703        assert_eq!(
704            decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, None, now),
705            BedtimeAction::Fire
706        );
707    }
708
709    #[test]
710    fn bedtime_re_fires_after_interval() {
711        let last = Instant::now();
712        let now = last + Duration::from_secs(1800);
713        assert_eq!(
714            decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, Some(last), now),
715            BedtimeAction::Fire
716        );
717    }
718
719    #[test]
720    fn bedtime_resets_only_inside_interval() {
721        // Half-way through 30min interval — too soon to re-fire.
722        let last = Instant::now();
723        let now = last + Duration::from_secs(900);
724        assert_eq!(
725            decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, Some(last), now),
726            BedtimeAction::ResetTimersOnly
727        );
728    }
729
730    #[test]
731    fn bedtime_window_handles_midnight_wrap() {
732        let now = Instant::now();
733        // 02:00 should still be in window 22:00–06:00
734        assert_eq!(
735            decide_bedtime(true, 2 * 60, 22 * 60, 6 * 60, 1800, None, now),
736            BedtimeAction::Fire
737        );
738    }
739
740    #[test]
741    fn bedtime_handles_clock_skew_safely() {
742        // last_sleep in the future shouldn't panic; saturating means
743        // elapsed == 0, so we land in ResetTimersOnly until time catches up.
744        let now = Instant::now();
745        let future = now + Duration::from_secs(60);
746        assert_eq!(
747            decide_bedtime(true, 23 * 60, 22 * 60, 6 * 60, 1800, Some(future), now),
748            BedtimeAction::ResetTimersOnly
749        );
750    }
751}