Skip to main content

entracte_lib/scheduler/
timers.rs

1use std::time::{Duration, Instant};
2
3use chrono::{Datelike, Local, Timelike};
4
5use super::types::{BreakDelivery, 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/// Re-anchor the micro / long interval clocks to `now` and clear the
58/// deferral / postpone / warn state, without disturbing `last_sleep`,
59/// `active_break`, or the fixed-time dedupe keys. `now` is injected so
60/// the re-anchor is a pure function over `BreakTimers` and a timestamp.
61///
62/// Used on two paths that both want "fresh intervals from this instant":
63/// a profile switch ([`reset_timers_keep_sleep`]) and a resume from pause
64/// ([`reanchor_intervals_on_resume`]). Sleep/bedtime semantics and any
65/// fixed-time schedule survive untouched — only the interval anchors and
66/// their per-cycle flags move.
67pub 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
78/// Reset the micro / long timers and clear deferral / postpone state
79/// without disturbing `last_sleep` or `active_break`. Called when the
80/// active profile switches: a new profile gets fresh intervals but we
81/// don't want to re-fire a sleep prompt that's already shown today.
82pub fn reset_timers_keep_sleep(t: &mut BreakTimers) {
83    reanchor_intervals_to(t, Instant::now());
84}
85
86/// Re-anchor the interval clocks when the scheduler resumes from a pause
87/// (timed-pause expiry or a manual "Resume"). Paused time must not count
88/// toward break intervals: without this, an hour-long pause leaves
89/// `last_micro` / `last_long` an hour in the past, so the very next tick
90/// sees every interval already overdue — the tray reads `0:00` and a
91/// break fires the instant the user resumes (#134).
92///
93/// Re-anchoring to the resume `now` restarts each interval from a full
94/// period and suppresses the spurious immediate fire. It deliberately
95/// leaves `last_sleep` and the fixed-time dedupe keys alone, so bedtime
96/// re-prompt cadence and fixed-time schedules keep their own clocks: a
97/// fixed-time break legitimately due at the resume minute still fires via
98/// its own date/minute path.
99pub fn reanchor_intervals_on_resume(t: &mut BreakTimers, now: Instant) {
100    reanchor_intervals_to(t, now);
101}
102
103/// Apply the timer bookkeeping for a scheduled micro/long break that has
104/// just fired. Pure analogue of the inline resets in `run_loop`'s
105/// fixed-time and interval fire paths, so the field-by-field divergence
106/// between the two break kinds (and between a fixed vs interval fire) is
107/// unit-testable without a windowing runtime or a live clock.
108///
109/// - A `Long` fire resets *both* interval clocks (a long break implies a
110///   micro break just happened) and clears both warn/deferral flags; a
111///   `Micro` fire touches only the micro fields.
112/// - `now` is injected (the live call site passes `Instant::now()`).
113/// - `fixed_key` is `Some((local-date, minute-of-day))` for a fixed-time
114///   fire — recording the dedupe key — and `None` for an interval fire.
115/// - `active_break` is only stashed for deliveries that hold the screen
116///   (`Overlay` / `Windowed`); a `Notification` leaves it unset.
117///
118/// `Sleep` never reaches here (it uses the bedtime fire path) and is a
119/// no-op for the per-kind resets.
120pub 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
154/// Clear the "resume last skipped break" slot. Returns `true` iff a
155/// stored break was actually cleared (used to decide whether to emit
156/// the `last_break:changed` event).
157pub 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
166/// Minutes since local midnight (0..1440). The unit used everywhere
167/// the scheduler reasons about time-of-day windows (work hours,
168/// bedtime window, fixed-time break list).
169pub fn current_minutes() -> u32 {
170    let now = Local::now();
171    now.hour() * 60 + now.minute()
172}
173
174/// ISO-8601 date in local time (`"YYYY-MM-DD"`). Used to detect
175/// midnight rollovers for screen-time / fixed-time dedupe state.
176pub fn local_today_string() -> String {
177    Local::now().date_naive().format("%Y-%m-%d").to_string()
178}
179
180/// The current local weekday as days-since-Monday (`0` = Monday … `6` =
181/// Sunday). Matches the bit layout of `Settings::work_days_mask` so the
182/// work-window day check can index it directly.
183pub fn current_weekday() -> u32 {
184    Local::now().weekday().num_days_from_monday()
185}
186
187/// Parse `"HH:MM"` (or `"H:MM"`) into minutes since midnight.
188/// Returns `None` on anything out of range or unparseable — used to
189/// filter the user's fixed-time list without spilling errors.
190pub 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
204/// Dedupe gate for fixed-time fires: `true` unless we already fired
205/// this exact `(date, minute)` slot. Prevents the 1Hz tick from firing
206/// the same fixed slot up to 60 times, and (because the key includes
207/// the local date) stays correct across DST: `02:00` on a "fall back"
208/// day fires once even though the wall clock visits it twice, and
209/// `02:30` on a "spring forward" day simply never matches.
210pub 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
221/// True iff `now` (minutes since midnight) falls inside `[start, end)`,
222/// with wrap-around: a window like `22:00`–`06:00` correctly straddles
223/// midnight. `start == end` is treated as an empty window.
224pub 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
235/// True iff the work window is open right now: the time-of-day is inside
236/// `[start, end)` **and** the relevant weekday's bit is set in
237/// `days_mask`. `today` is days-since-Monday (`0`=Mon … `6`=Sun), the
238/// same layout as the mask.
239///
240/// For a window that wraps past midnight (`start > end`, e.g.
241/// 22:00–06:00) the early-morning portion is gated on the day the window
242/// *began* — Friday's 22:00 shift still covers Saturday 02:00 even when
243/// Saturday is off, and an off Sunday won't start a window that bleeds
244/// into Monday morning. A window that doesn't wrap is gated on `today`.
245///
246/// `days_mask == 0` (no day enabled) always returns false; callers gate
247/// on `work_window_enabled` before reaching here, so that simply means
248/// the window never opens.
249pub 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
261/// True iff an interval-mode break of this kind is due to fire now.
262///
263/// All inputs are explicit so callers can drive it with a synthetic
264/// `Instant` in tests. `now.saturating_duration_since(last_fire)` mirrors
265/// the production check `last_fire.elapsed()` with a frozen clock.
266///
267/// `mode_includes_interval` is the de-stringified equivalent of the
268/// settings's `*_schedule_mode` ∈ {`"interval"`, `"both"`} — done at the
269/// call site so this stays clock-agnostic.
270pub 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/// The four conditions that gate a pre-break warning before any timing
285/// is considered: the feature must be on, the schedule must include
286/// interval breaks, the user must not be idle-suppressed, and we must not
287/// have already warned this cycle. Grouped so each is set by name.
288#[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
296/// True iff the pre-break notification for this kind should fire now —
297/// i.e. we're inside the lead window before a due interval break, and
298/// we haven't already shown the notification for this cycle.
299///
300/// Pure analogue of the inline check in `run_loop`. Decoupled from
301/// `Scheduler` state so tests can drive it with synthetic instants. The
302/// four short-circuit conditions are grouped into [`PrebreakGate`] so
303/// call sites set each by name instead of passing a run of positional
304/// booleans.
305pub 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/// Decision returned by `decide_bedtime` — fully captures what the tick
324/// should do with the bedtime window. The caller still performs the
325/// side effects (overlay, hooks, logging, timer mutation).
326#[derive(Debug, PartialEq, Eq, Clone, Copy)]
327pub enum BedtimeAction {
328    /// In the bedtime window AND it's time to (re)show the prompt.
329    Fire,
330    /// In the bedtime window but the per-window interval hasn't elapsed
331    /// since the last prompt — only reset the micro/long anchors so
332    /// they don't pile up while the user is winding down.
333    ResetTimersOnly,
334    /// Outside the bedtime window — bedtime branch is a no-op this tick.
335    NotInWindow,
336}
337
338/// The configured bedtime window: whether the feature is on, its
339/// start/end minute-of-day bounds, and the per-window re-prompt interval.
340/// Grouped so `decide_bedtime`'s call sites set each field by name rather
341/// than passing four positional values that are easy to transpose.
342#[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
350/// Pure bedtime decision: combine the time-of-day window, the per-window
351/// interval, and the `last_sleep` anchor into one of three actions.
352///
353/// `last_sleep == None` always fires on the first tick of the window —
354/// the cap only kicks in for re-prompts.
355///
356/// `resumed_from_suspend` is `true` on the single tick that follows a
357/// wake from sleep (detected via a wall-clock jump in the run loop). The
358/// monotonic `last_sleep` anchor can show an arbitrarily large elapsed
359/// interval across a suspend, which would otherwise fire a stale catch-up
360/// prompt the instant the lid opens. When we've *already* prompted this
361/// window (`last_sleep` is `Some`), we demote that catch-up to
362/// `ResetTimersOnly` so opening the laptop mid-window stays quiet. A
363/// first entry into the window (`last_sleep` is `None`, e.g. the laptop
364/// was closed before bedtime and opened inside it) still fires normally.
365pub 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
389/// Decide whether a due break should be delayed because the user is
390/// mid-keystroke. Returns `true` while we should keep waiting and
391/// `false` once either the user has paused typing OR the deferral cap
392/// has been reached (so we don't postpone indefinitely).
393///
394/// `idle_secs` is `None` when idle detection is unavailable this tick
395/// (e.g. Wayland exposes no portable HID-idle query, so the probe keeps
396/// failing). We can't tell whether the user is typing then, so we don't
397/// defer — otherwise the "is the user idle?" proxy is permanently 0 and
398/// every break stalls for the full deferral cap before appearing (#67).
399///
400/// `deferred_since` is the instant the current defer-streak started,
401/// or `None` if this is the first tick of the streak.
402pub 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
425/// How many times the user has postponed the current break of this
426/// kind. `Sleep` always returns 0 (sleep prompts don't escalate).
427pub 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
435/// Zero out the postpone counter for this kind. Called when a break
436/// completes successfully — the user gets a fresh budget next time.
437pub 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        // With every day enabled, the day gate is a no-op: the result is
493        // exactly `in_window` regardless of `today`.
494        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        // 09:00–17:00, weekdays only (Mon..Fri = bits 0..4).
509        let weekdays = 0b001_1111;
510        // 10:00 Wednesday (today=2) → enabled.
511        assert!(work_window_active(600, 540, 1080, 2, weekdays));
512        // 10:00 Saturday (today=5) → disabled even though inside the hours.
513        assert!(!work_window_active(600, 540, 1080, 5, weekdays));
514        // 20:00 Wednesday → outside the hours regardless of the day.
515        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        // 22:00–06:00 window, enabled Friday only (bit 4).
528        let friday = 1 << 4;
529        // 23:00 Friday (today=4) → the window started today → active.
530        assert!(work_window_active(1380, 1320, 360, 4, friday));
531        // 02:00 Saturday (today=5) → the window started *Friday*, so it's
532        // still Friday's shift → active even though Saturday is off.
533        assert!(work_window_active(120, 1320, 360, 5, friday));
534        // 02:00 Friday (today=4) belongs to Thursday's shift → Thursday is
535        // off → not active.
536        assert!(!work_window_active(120, 1320, 360, 4, friday));
537        // 23:00 Saturday (today=5) starts Saturday's shift → off.
538        assert!(!work_window_active(1380, 1320, 360, 5, friday));
539    }
540
541    #[test]
542    fn work_window_active_wrap_sunday_to_monday_morning() {
543        // 23:00–01:00 window, enabled Sunday only (bit 6). Monday 00:30
544        // (today=0) belongs to Sunday's shift: (0 + 6) % 7 == 6 → active.
545        let sunday = 1 << 6;
546        assert!(work_window_active(30, 1380, 60, 0, sunday));
547        // Sunday 00:30 (today=6) belongs to Saturday's shift → off.
548        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        // Crossing midnight resets the dedupe so the next day's first
610        // hit of the fixed-time slot fires.
611        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        // North-American "fall back": at 02:00 local the clock jumps
618        // back to 01:00 so wall-clock minutes 60..119 are traversed
619        // twice. Same-day dedupe must keep a single fire per (date,
620        // minute) — otherwise every fixed-time slot in that hour would
621        // fire twice on DST end.
622        let last = ("2026-11-01".to_string(), 90u32); // 01:30 first pass
623        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        // "Spring forward": 02:00 → 03:00, so minutes 120..179 (02:00–02:59)
629        // are skipped entirely. The minute simply never appears on the
630        // wall clock, so dedupe doesn't need a fix for it — but if a
631        // previous day's fire was at 02:30 the next day at 02:30 (a real
632        // minute on a non-DST day) should still re-fire, which it does
633        // because the date key differs.
634        let last = ("2026-03-08".to_string(), 150u32); // 02:30 the day before DST
635        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        // Wayland (#67): no idle reading this tick. We can't tell the
660        // user is typing, so we must fire rather than stall for the cap.
661        let now = Instant::now();
662        assert!(!should_defer_for_typing(true, None, 10, None, 60, now));
663        // Even mid-streak, an unavailable reading releases the defer.
664        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        // Anchor at `started` and derive `now = started + 30s`; never
691        // subtract from `Instant::now()` (panics on Windows when the
692        // monotonic clock is younger than the offset).
693        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        // The #134 core: after an hour paused the anchors are an hour
799        // stale; re-anchoring to the resume instant means no interval is
800        // due (full period remaining) on the very next tick.
801        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        // Bedtime cadence and fixed-time dedupe keys must survive a resume
876        // so resuming doesn't re-fire a sleep prompt or a fixed-time slot
877        // already handled today.
878        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    // `interval_break_due` — the workhorse decision for "is this break
894    // due to fire on this tick?". Frozen clock here is built by
895    // anchoring at `Instant::now()` and adding offsets to derive "later"
896    // points — never subtracting from `now()`, because on Windows the
897    // monotonic clock can underflow on a fresh runner (`Instant::sub`
898    // panics if the result would be before boot).
899
900    #[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        // mode "fixed" → mode_includes_interval is false → no fire even
924        // though the interval has elapsed. Catches the regression where
925        // a user switched to fixed-only and intervals kept firing.
926        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        // `last_fire` in the future shouldn't panic — saturating_sub
941        // returns zero, which fails the `>= interval` check.
942        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    // `prebreak_warn_due` — fires once per interval cycle, in a narrow
948    // band before the break itself. The `already_warned` flag is the
949    // dedupe gate.
950
951    /// The "would warn if timing is right" gate: feature on, interval
952    /// mode, not yet warned, not idle. Variants flip one field via
953    /// struct-update syntax (`PrebreakGate { enabled: false, ..warn_gate() }`).
954    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        // 50s before a 1200s break, lead is 60s → in the warn band.
966        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        // 100s before a 1200s break, lead is 60s → outside warn band.
974        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        // Once we've hit the interval the break itself fires — warning
982        // shouldn't re-fire post-interval.
983        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        // saturating_sub means warn_at = 0 — the warning fires
1052        // immediately after the previous break. Unusual config but must
1053        // not panic or warn forever.
1054        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    /// Drive a full interval cycle one simulated 1Hz tick at a time the
1060    /// way the run loop does: at each tick, evaluate `prebreak_warn_due`
1061    /// against the live `micro_warned` flag (setting it when it fires),
1062    /// then `interval_break_due`; when the break is due, apply
1063    /// `record_scheduled_fire` (which clears the warn flag) and re-anchor.
1064    /// Returns the elapsed-seconds at every tick the warn fired across two
1065    /// back-to-back cycles, so the test can assert the warning surfaces
1066    /// exactly once per cycle and re-arms for the next break (#135).
1067    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    // `decide_bedtime` — three-way decision combining window membership
1120    // and per-window interval. The first tick of the window always
1121    // fires (`None` last_sleep).
1122
1123    /// 22:00–06:00 window, 30-min re-prompt — the bulk of these cases.
1124    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    /// 22:00–09:00 window, 5-min re-prompt — the resume-from-suspend cases.
1134    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        // 12:00, window 22:00–06:00
1147        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        // Half-way through 30min interval — too soon to re-fire.
1193        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        // 02:00 should still be in window 22:00–06:00
1205        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        // last_sleep in the future shouldn't panic; saturating means
1214        // elapsed == 0, so we land in ResetTimersOnly until time catches up.
1215        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        // Issue #61: prompt fired in the evening (last_sleep = Some), the
1226        // laptop slept for hours, and the user opens it still inside the
1227        // overnight window. The monotonic elapsed dwarfs the interval, so
1228        // the plain rule would fire — but the wake tick must stay quiet.
1229        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        // Without the resume flag the same inputs would (wrongly, for the
1236        // user) re-fire — proving the flag is what suppresses it.
1237        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        // Laptop closed before bedtime, opened inside the window: no prompt
1246        // has fired this window (last_sleep = None), so even a wake tick
1247        // should fire the first reminder.
1248        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        // A wake well outside the window is still just NotInWindow.
1258        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}