Skip to main content

entracte_lib/scheduler/
run_loop.rs

1use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering};
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use sysinfo::{ProcessesToUpdate, System};
5use tauri::{AppHandle, Emitter, Runtime};
6use tokio::time::sleep;
7
8use crate::dnd;
9use crate::hooks::{self, HookContext, HookEvent};
10use crate::proc_match::process_match_lower;
11use crate::stats::{EventPayload, GuardReason, Logger};
12
13use super::chores;
14use super::idle;
15use super::overlay::deliver_break;
16use super::pause::{persist_pause, PauseState};
17use super::screen_time::{persist_screen_time, rollover_if_new_day, should_remind_screen_time};
18use super::session_lock;
19use super::settings::{delivery_for, Settings};
20use super::timers::{
21    current_minutes, current_weekday, decide_bedtime, interval_break_due, local_today_string,
22    prebreak_warn_due, reanchor_intervals_on_resume, record_scheduled_fire,
23    should_defer_for_typing, should_fire_fixed_now, work_window_active, BedtimeAction,
24    BedtimeWindow, PrebreakGate,
25};
26use super::types::{BreakDelivery, BreakEvent, BreakKind, SuppressReason};
27use super::Scheduler;
28
29/// Cheap atomic-load check that the run loop reads at the top of
30/// every tick. Pulled out of `run_loop` so the early-out condition
31/// is unit-testable without driving the full 1Hz loop body, which
32/// is bound to the production `AppHandle<Wry>` runtime and a real
33/// `Scheduler` with its camera/video/logger side threads.
34#[inline]
35fn import_pending(flag: &AtomicBool) -> bool {
36    flag.load(Ordering::Relaxed)
37}
38
39/// Inter-tick wall-clock gap above which we treat the tick as the first
40/// one after a wake from suspend. Well clear of the 1s cadence and any
41/// scheduler jitter, but far below the smallest useful bedtime interval.
42const SUSPEND_GAP_THRESHOLD: Duration = Duration::from_secs(30);
43
44/// Whether the gap between the previous tick's wall clock and `now`
45/// indicates a wake from suspend (or a forward clock leap). Pulled out
46/// of the loop body so the threshold logic is unit-testable without
47/// driving the 1Hz loop. A backwards clock step yields `Err` from
48/// `duration_since` and is treated as "not resumed".
49#[inline]
50fn resumed_after_gap(prev_wall: SystemTime, now_wall: SystemTime, threshold: Duration) -> bool {
51    now_wall
52        .duration_since(prev_wall)
53        .map(|gap| gap >= threshold)
54        .unwrap_or(false)
55}
56
57pub(super) async fn run_loop(app: AppHandle, sched: Scheduler) {
58    let mut sysinfo_system: Option<System> = None;
59    // `Instant - Duration` panics if the result would precede the
60    // monotonic clock's start, which on a freshly-booted Windows runner
61    // (clock younger than 60s) means a hard crash before the first tick.
62    let mut last_app_refresh = Instant::now()
63        .checked_sub(Duration::from_secs(60))
64        .unwrap_or_else(Instant::now);
65    let mut app_pause_active = false;
66    // Wall-clock anchor for wake-from-suspend detection. The loop ticks
67    // at 1Hz, so a jump far beyond that between ticks means the machine
68    // was asleep (or the clock leapt). `SystemTime` is used rather than
69    // `Instant` because it reflects the wall clock regardless of whether
70    // the monotonic clock counts suspended time on this platform.
71    let mut last_tick_wall = SystemTime::now();
72    // Exponential back-off for a persistently-failing idle probe so we
73    // stop re-querying (and re-spamming) a windowing-system extension
74    // that isn't there. See `IdleProbeBackoff`.
75    let mut idle_backoff = IdleProbeBackoff::new();
76
77    loop {
78        sleep(Duration::from_secs(1)).await;
79
80        let now_wall = SystemTime::now();
81        let resumed_from_suspend =
82            resumed_after_gap(last_tick_wall, now_wall, SUSPEND_GAP_THRESHOLD);
83        if resumed_from_suspend {
84            let gap = now_wall
85                .duration_since(last_tick_wall)
86                .map(|d| d.as_secs())
87                .unwrap_or(0);
88            log::info!("scheduler: resumed from suspend after {gap}s gap");
89        }
90        last_tick_wall = now_wall;
91
92        // Early-out while an import is mid-flight. This is an
93        // optimisation, not the actual safety mechanism — the
94        // half-restored state we want to avoid observing is guarded
95        // by the in-memory tokio mutexes that `apply_bundle_to_scheduler`
96        // holds while updating settings/profiles/pause/etc., so a
97        // tick that misses the flag still acquires those mutexes
98        // before reading. `import_pending` does a `Relaxed` load,
99        // which is correct because the mutexes provide the necessary
100        // acquire/release for the data; the flag is just here to
101        // skip cheaply during the seconds the import is doing disk
102        // I/O.
103        if import_pending(&sched.import_in_progress) {
104            continue;
105        }
106
107        let now = Instant::now();
108        let mut just_resumed = false;
109        {
110            let mut state = sched.pause_state.lock().await;
111            if let PauseState::PausedUntil(Some(t)) = *state {
112                if now >= t {
113                    *state = PauseState::Running;
114                    just_resumed = true;
115                }
116            }
117            if !matches!(*state, PauseState::Running) {
118                continue;
119            }
120        }
121        if just_resumed {
122            reanchor_intervals_on_resume(&mut *sched.timers.lock().await, Instant::now());
123            persist_pause(&sched.pause_path, &PauseState::Running);
124            sched.logger.log(EventPayload::PauseEnd);
125            let _ = app.emit("pause:changed", false);
126        }
127
128        // Reset before re-evaluating guards. Each branch below writes
129        // its `SuppressReason` if it fires; if none fire the value
130        // stays at 0 and the tray returns to the Normal icon.
131        sched.auto_suppress_reason.store(0, Ordering::Relaxed);
132
133        let s = sched.settings.lock().await.clone();
134        // Mirror the "pause media during breaks" setting into the media
135        // module so the synchronous overlay path can read it lock-free
136        // when a break fires (#77).
137        crate::media::set_enabled(s.pause_media_during_breaks);
138        let now_min = current_minutes();
139        let today_weekday = current_weekday();
140
141        // Probing idle round-trips to the windowing system (and, on
142        // GNOME/Wayland, to Mutter over the session bus) and isn't free on
143        // macOS either, so fetch once per tick and reuse for screen-time,
144        // idle-suppression, and the typing-defer check. `idle::idle_secs`
145        // is the only platform-bound part; `resolve_idle_secs` holds the
146        // (unit-tested) back-off decision.
147        let idle_reading = resolve_idle_secs(&mut idle_backoff, idle::idle_secs);
148        // Unknown idle maps to 0 ("active") for screen-time and
149        // suppression; the typing-defer path below keeps the `Option` so
150        // it can tell "active" apart from "couldn't measure" (#67).
151        let raw_idle_secs = idle_reading.unwrap_or(0);
152        // A locked screen is a stronger AFK signal than HIDIdleTime —
153        // `caffeinate -u`, Zoom meetings, and synthetic-input utilities
154        // can keep the HID counter at zero while the human is gone, but
155        // they can't unlock the workstation. When the OS reports the
156        // session as locked, promote `idle_secs` past both thresholds
157        // so the screen-time, suppression, and typing-defer paths
158        // below all treat the user as idle.
159        let locked = session_lock::screen_locked();
160        let idle_secs = promote_idle_for_lock(raw_idle_secs, locked, &s);
161        let idle_for_typing = idle_for_typing_defer(idle_reading, idle_secs, locked);
162        let is_active = idle_secs < s.micro_idle_reset_secs;
163        let today_str = local_today_string();
164        let budget_secs = s.daily_screen_time_budget_minutes.saturating_mul(60);
165        let remind_again_secs = s.daily_screen_time_remind_again_minutes.saturating_mul(60);
166        let mut fire_screen_time_reminder = false;
167        {
168            let mut st = sched.screen_time.lock().await;
169            let rolled = rollover_if_new_day(&mut st, &today_str);
170            let mut changed = rolled;
171            if is_active {
172                st.seconds = st.seconds.saturating_add(1);
173                changed = true;
174            }
175            if should_remind_screen_time(
176                s.daily_screen_time_enabled,
177                st.seconds,
178                budget_secs,
179                st.last_reminder_epoch_secs,
180                remind_again_secs,
181                super::pause::now_epoch_secs(),
182            ) {
183                st.last_reminder_epoch_secs = Some(super::pause::now_epoch_secs());
184                fire_screen_time_reminder = true;
185                changed = true;
186            }
187            if changed {
188                persist_screen_time(&sched.screen_time_path, &st);
189            }
190        }
191        if fire_screen_time_reminder {
192            notify_screen_time_budget(&app, s.daily_screen_time_budget_minutes);
193            let _ = app.emit("screen_time:reminder", s.daily_screen_time_budget_minutes);
194        }
195
196        // The fixed-time dedupe key is `(local-date, minute-of-day)`, so
197        // midnight rollover is handled naturally: a new date string never
198        // matches yesterday's stored entry. No explicit reset needed here.
199
200        let bedtime_decision = {
201            let t = sched.timers.lock().await;
202            decide_bedtime(
203                BedtimeWindow {
204                    enabled: s.bedtime_enabled,
205                    start_min: s.bedtime_start_minutes,
206                    end_min: s.bedtime_end_minutes,
207                    interval_secs: s.bedtime_interval_secs,
208                },
209                now_min,
210                t.last_sleep,
211                now,
212                resumed_from_suspend,
213            )
214        };
215        if !matches!(bedtime_decision, BedtimeAction::NotInWindow) {
216            if matches!(bedtime_decision, BedtimeAction::Fire) {
217                let intensity = sched.stats.lock().await.intensity();
218                deliver_sleep_break(&app, &sched, &s, intensity);
219                let mut t = sched.timers.lock().await;
220                t.last_sleep = Some(Instant::now());
221                t.last_micro = Instant::now();
222                t.last_long = Instant::now();
223                t.micro_deferred_since = None;
224                t.long_deferred_since = None;
225                t.active_break = Some(BreakKind::Sleep);
226            } else {
227                let mut t = sched.timers.lock().await;
228                t.last_micro = Instant::now();
229                t.last_long = Instant::now();
230                t.micro_deferred_since = None;
231                t.long_deferred_since = None;
232                // On the wake tick we demoted a stale catch-up fire to a
233                // reset; re-anchor `last_sleep` to now so the next (no
234                // longer "resumed") tick doesn't immediately re-fire on
235                // the same huge elapsed interval. Normal in-interval
236                // resets leave `last_sleep` alone so the re-prompt cadence
237                // keeps counting from the real last fire.
238                if resumed_from_suspend {
239                    t.last_sleep = Some(Instant::now());
240                }
241            }
242            // Bedtime has its own tray snapshot (`TrayCountdownSnapshot::Bedtime`),
243            // so we don't need to store a `SuppressReason` here — the
244            // tray reads `bedtime_active` directly and shows the moon icon.
245            continue;
246        }
247        // Note: `last_sleep` is intentionally *not* cleared here. Earlier
248        // versions zeroed it on every non-bedtime tick, which meant
249        // briefly exiting the bedtime window (clock skew, end-minute edit)
250        // and re-entering would re-fire immediately. The `decide_bedtime`
251        // interval check on the persisted `Instant` is the only re-fire
252        // gate; on the next day the elapsed time naturally exceeds any
253        // sane `bedtime_interval_secs`, so a fresh bedtime entry fires.
254
255        // Morning chore prompt: the first time the work window opens each day
256        // with an empty list, open Preferences to the chores input so the
257        // user can plan the day. Fires at most once per day (tracked by the
258        // persisted `prompted_date`) and independently of the break
259        // suppressions below — it's a planning nudge, not a break.
260        {
261            let in_work = !s.work_window_enabled
262                || work_window_active(
263                    now_min,
264                    s.work_start_minutes,
265                    s.work_end_minutes,
266                    today_weekday,
267                    s.work_days_mask,
268                );
269            let mut c = sched.chores.lock().await;
270            let rolled = chores::rollover_if_new_day(&mut c, &today_str);
271            let prompt = chores::should_prompt_morning_chores(
272                s.morning_chore_prompt_enabled,
273                in_work,
274                now_min,
275                &c,
276                &today_str,
277            );
278            if prompt {
279                c.prompted_date = today_str.clone();
280            }
281            if rolled || prompt {
282                chores::persist_chores(&sched.chores_path, &c);
283            }
284            drop(c);
285            if prompt {
286                crate::window::show_main_window(&app);
287                let _ = app.emit("chores:prompt", ());
288            }
289        }
290
291        // Live readings for the guard decision. Short-circuit each
292        // call on the matching setting so `dnd::is_active()` and the
293        // process-scan only run when the user has opted in.
294        let dnd_live = s.pause_during_dnd && dnd::is_active();
295        let camera_live = s.pause_during_camera && sched.camera_active.load(Ordering::Relaxed);
296        let video_live = s.pause_during_video && sched.video_active.load(Ordering::Relaxed);
297        if s.app_pause_enabled && !s.app_pause_list.is_empty() {
298            if last_app_refresh.elapsed() >= Duration::from_secs(5) {
299                let sys = sysinfo_system.get_or_insert_with(System::new);
300                sys.refresh_processes(ProcessesToUpdate::All, false);
301                app_pause_active = sys.processes().values().any(|p| {
302                    // Lowercase the live process name once per process; the
303                    // targets are pre-lowercased at settings load/update.
304                    let proc_lower = p.name().to_string_lossy().to_lowercase();
305                    s.derived
306                        .app_pause_targets_lower
307                        .iter()
308                        .any(|target| process_match_lower(&proc_lower, target))
309                });
310                last_app_refresh = Instant::now();
311            }
312        } else {
313            sysinfo_system = None;
314            app_pause_active = false;
315        }
316
317        if let Some(outcome) = evaluate_guards(
318            &s,
319            TickClock {
320                now_min,
321                weekday: today_weekday,
322            },
323            dnd_live,
324            camera_live,
325            video_live,
326            app_pause_active,
327            sched.plugin_suppress.load(Ordering::Relaxed),
328        ) {
329            let mut t = sched.timers.lock().await;
330            if let Some(guard_reason) = outcome.log_as {
331                log_suppressions(&sched.logger, &s, &t, guard_reason);
332            }
333            t.last_micro = Instant::now();
334            t.last_long = Instant::now();
335            t.micro_deferred_since = None;
336            t.long_deferred_since = None;
337            sched
338                .auto_suppress_reason
339                .store(outcome.reason.as_u8(), Ordering::Relaxed);
340            continue;
341        }
342
343        // Fixed times are pre-parsed to minutes-since-midnight at settings
344        // load/update (see `Settings::rebuild_derived`), so the per-tick
345        // check is a plain `== now_min` rather than re-running `parse_hhmm`
346        // over every `"HH:MM"` string on all 86,400 ticks a day.
347        let long_fixed_due = fixed_break_due(BreakKind::Long, &s, now_min);
348        let micro_fixed_due = fixed_break_due(BreakKind::Micro, &s, now_min);
349
350        if long_fixed_due || micro_fixed_due {
351            let (fire_long, fire_micro) = {
352                let t = sched.timers.lock().await;
353                (
354                    long_fixed_due
355                        && should_fire_fixed_now(
356                            &today_str,
357                            now_min,
358                            t.last_long_fixed_fire.as_ref(),
359                        ),
360                    micro_fixed_due
361                        && should_fire_fixed_now(
362                            &today_str,
363                            now_min,
364                            t.last_micro_fixed_fire.as_ref(),
365                        ),
366                )
367            };
368            // Fixed-time fires bypass the idle gate: the clock is the signal, not user activity.
369            let fixed_key = Some((today_str.clone(), now_min));
370            if fire_long {
371                fire_scheduled_break(&app, &sched, &s, BreakKind::Long, fixed_key).await;
372                continue;
373            }
374            if fire_micro {
375                fire_scheduled_break(&app, &sched, &s, BreakKind::Micro, fixed_key).await;
376                continue;
377            }
378        }
379
380        let micro_interval_active = s.interval_active(BreakKind::Micro);
381        let long_interval_active = s.interval_active(BreakKind::Long);
382
383        let (micro_idle_suppressed, long_idle_suppressed) = (
384            idle_secs >= s.micro_idle_reset_secs,
385            idle_secs >= s.long_idle_reset_secs,
386        );
387
388        if micro_idle_suppressed || long_idle_suppressed {
389            let mut t = sched.timers.lock().await;
390            log_suppressions(&sched.logger, &s, &t, GuardReason::Idle);
391            if micro_idle_suppressed {
392                t.last_micro = Instant::now();
393                t.micro_deferred_since = None;
394            }
395            if long_idle_suppressed {
396                t.last_long = Instant::now();
397                t.long_deferred_since = None;
398            }
399            if micro_idle_suppressed && long_idle_suppressed {
400                continue;
401            }
402        }
403
404        let tick_now = Instant::now();
405
406        if s.prebreak_notification_enabled && s.prebreak_notification_seconds > 0 {
407            let mut t = sched.timers.lock().await;
408            if prebreak_warn_due(
409                PrebreakGate {
410                    enabled: s.long_enabled,
411                    mode_includes_interval: long_interval_active,
412                    already_warned: t.long_warned,
413                    idle_suppressed: long_idle_suppressed,
414                },
415                t.last_long,
416                s.long_interval_secs,
417                s.prebreak_notification_seconds,
418                tick_now,
419            ) {
420                notify_break_coming(&app, BreakKind::Long, s.prebreak_notification_seconds);
421                t.long_warned = true;
422            }
423            if prebreak_warn_due(
424                PrebreakGate {
425                    enabled: s.micro_enabled,
426                    mode_includes_interval: micro_interval_active,
427                    already_warned: t.micro_warned,
428                    idle_suppressed: micro_idle_suppressed,
429                },
430                t.last_micro,
431                s.micro_interval_secs,
432                s.prebreak_notification_seconds,
433                tick_now,
434            ) {
435                notify_break_coming(&app, BreakKind::Micro, s.prebreak_notification_seconds);
436                t.micro_warned = true;
437            }
438        }
439
440        let (should_fire_long, should_fire_micro) = {
441            let t = sched.timers.lock().await;
442            (
443                interval_break_due(
444                    s.long_enabled,
445                    long_interval_active,
446                    t.last_long,
447                    s.long_interval_secs,
448                    long_idle_suppressed,
449                    tick_now,
450                ),
451                interval_break_due(
452                    s.micro_enabled,
453                    micro_interval_active,
454                    t.last_micro,
455                    s.micro_interval_secs,
456                    micro_idle_suppressed,
457                    tick_now,
458                ),
459            )
460        };
461
462        if should_fire_long || should_fire_micro {
463            let mut t = sched.timers.lock().await;
464            let kind = if should_fire_long {
465                BreakKind::Long
466            } else {
467                BreakKind::Micro
468            };
469            let deferred_since = match kind {
470                BreakKind::Long => t.long_deferred_since,
471                BreakKind::Micro => t.micro_deferred_since,
472                BreakKind::Sleep => None,
473            };
474            let defer = should_defer_for_typing(
475                s.delay_break_if_typing,
476                idle_for_typing,
477                s.typing_grace_secs,
478                deferred_since,
479                s.typing_max_deferral_secs,
480                tick_now,
481            );
482            if defer {
483                let newly_deferred = deferred_since.is_none();
484                match kind {
485                    BreakKind::Long => {
486                        if newly_deferred {
487                            t.long_deferred_since = Some(tick_now);
488                            sched.logger.log(EventPayload::GuardSuppress {
489                                kind: BreakKind::Long,
490                                reason: GuardReason::Typing,
491                            });
492                        }
493                    }
494                    BreakKind::Micro => {
495                        if newly_deferred {
496                            t.micro_deferred_since = Some(tick_now);
497                            sched.logger.log(EventPayload::GuardSuppress {
498                                kind: BreakKind::Micro,
499                                reason: GuardReason::Typing,
500                            });
501                        }
502                    }
503                    BreakKind::Sleep => {}
504                }
505                continue;
506            }
507        }
508
509        if should_fire_long {
510            fire_scheduled_break(&app, &sched, &s, BreakKind::Long, None).await;
511        } else if should_fire_micro {
512            fire_scheduled_break(&app, &sched, &s, BreakKind::Micro, None).await;
513        }
514    }
515}
516
517/// Fire a scheduled micro/long break end to end: deliver it (see
518/// [`deliver_scheduled_break`]) and apply the post-fire timer bookkeeping
519/// under the timers lock (see [`record_scheduled_fire`]). `fixed_key` is
520/// `Some((today, minute))` for a fixed-time fire (recording the dedupe
521/// key) or `None` for an interval fire; the fire `Instant` is stamped here.
522///
523/// Also the on-demand entry for "take a long break now" (#258): the
524/// `start_long_break_now` command calls this with `Long`/`None` so a
525/// user-initiated long break behaves exactly like a due one — same delivery,
526/// same re-anchor of both interval clocks — and never doubles up later.
527pub(super) async fn fire_scheduled_break<R: Runtime>(
528    app: &AppHandle<R>,
529    sched: &Scheduler,
530    s: &Settings,
531    kind: BreakKind,
532    fixed_key: Option<(String, u32)>,
533) {
534    let delivery = deliver_scheduled_break(app, sched, s, kind).await;
535    let mut t = sched.timers.lock().await;
536    record_scheduled_fire(&mut t, kind, delivery, Instant::now(), fixed_key);
537}
538
539/// Build and surface a scheduled micro/long break: resolve the per-kind
540/// content from `s`, deliver it through the configured channel, fire the
541/// `BreakStart` hook, and log the event. Returns the resolved delivery so
542/// the caller can decide whether to mark an `active_break`.
543///
544/// Timer bookkeeping stays with the caller ([`fire_scheduled_break`]):
545/// fixed-time and interval fires reset different timer fields, so folding
546/// them in here would just move the divergence. `Sleep` goes through the
547/// bedtime path's own `overlay::fire_break` (different postpone semantics)
548/// and never reaches this helper.
549async fn deliver_scheduled_break<R: Runtime>(
550    app: &AppHandle<R>,
551    sched: &Scheduler,
552    s: &Settings,
553    kind: BreakKind,
554) -> BreakDelivery {
555    let intensity = sched.stats.lock().await.intensity();
556    let chore_prompt = sched.resolve_chore_prompt(kind).await;
557    let event = scheduled_break_event(kind, s, intensity, chore_prompt);
558    let duration_secs = event.duration_secs;
559    let enforceable = event.enforceable;
560    let delivery = delivery_for(kind, s);
561    deliver_break(
562        app,
563        &sched.current_break,
564        event,
565        delivery,
566        s.monitor_placement,
567        super::settings::windowed_fraction_for(kind, s),
568    );
569    hooks::run_hooks(
570        s,
571        HookEvent::BreakStart,
572        HookContext::with_kind_duration(kind, duration_secs),
573    );
574    super::exports::deliver_on_event(sched, HookEvent::BreakStart);
575    sched.logger.log(EventPayload::BreakStart {
576        kind,
577        duration_secs,
578        enforceable,
579    });
580    delivery
581}
582
583/// Surface a Sleep (bedtime) break: fire the overlay, run the start hook, and
584/// log the event. Timer bookkeeping stays with the caller in `run_loop`.
585/// Sleep breaks are always overlay; they never go through the delivery-routing
586/// logic used by [`deliver_scheduled_break`].
587fn deliver_sleep_break<R: Runtime>(
588    app: &AppHandle<R>,
589    sched: &Scheduler,
590    s: &Settings,
591    intensity: f32,
592) {
593    super::overlay::fire_break(
594        app,
595        &sched.current_break,
596        sleep_break_event(s, intensity),
597        s.monitor_placement,
598        super::settings::is_windowed_mode(BreakKind::Sleep, s),
599        super::settings::windowed_fraction_for(BreakKind::Sleep, s),
600    );
601    hooks::run_hooks(
602        s,
603        HookEvent::BreakStart,
604        HookContext::with_kind_duration(BreakKind::Sleep, s.bedtime_duration_secs),
605    );
606    super::exports::deliver_on_event(sched, HookEvent::BreakStart);
607    sched.logger.log(EventPayload::BreakStart {
608        kind: BreakKind::Sleep,
609        duration_secs: s.bedtime_duration_secs,
610        enforceable: true,
611    });
612}
613
614/// Build the `BreakEvent` for the bedtime (Sleep) path. Sleep breaks never
615/// carry a guided routine, so `routine_*` fields are always empty/None.
616fn sleep_break_event(s: &Settings, intensity: f32) -> BreakEvent {
617    BreakEvent {
618        kind: BreakKind::Sleep,
619        duration_secs: s.bedtime_duration_secs,
620        enforceable: true,
621        manual_finish: false,
622        postpone_available: false,
623        skip_available: false,
624        hints: s.sleep_hints.clone(),
625        hint_rotate_seconds: s.hint_rotate_seconds,
626        health_intensity: if s.break_health_enabled {
627            intensity
628        } else {
629            0.0
630        },
631        routine_steps: Vec::new(),
632        routine_pacing: None,
633        routine_max_step_secs: None,
634        routine_breath: None,
635        chore_prompt: None,
636    }
637}
638
639/// Resolve the per-kind `BreakEvent` content for a scheduled micro/long
640/// break. Pure (no I/O) so the field resolution — which fields each kind
641/// draws, the strict-mode postpone lock, the break-health intensity gate
642/// — is unit-testable without a windowing runtime. `intensity` is the
643/// live value from the stats lock; the helper applies the
644/// `break_health_enabled` gate. Sleep never reaches here (bedtime path).
645fn scheduled_break_event(
646    kind: BreakKind,
647    s: &Settings,
648    intensity: f32,
649    chore_prompt: Option<String>,
650) -> BreakEvent {
651    // Sleep is delivered through the bedtime path, never here; enforce that
652    // invariant with a panic rather than silently firing the bedtime fields.
653    assert!(
654        s.for_kind(kind).is_some(),
655        "sleep breaks use the bedtime fire path"
656    );
657    let (duration_secs, enforceable, manual_finish, hints) =
658        super::commands::breaks::fire_fields(kind, s);
659    let resolved = super::routines::resolve_routine(kind, s);
660    BreakEvent {
661        kind,
662        duration_secs,
663        enforceable,
664        manual_finish,
665        postpone_available: s.postpone_available_for(kind),
666        skip_available: s.skip_available_for(kind),
667        hints,
668        hint_rotate_seconds: s.hint_rotate_seconds,
669        health_intensity: if s.break_health_enabled {
670            intensity
671        } else {
672            0.0
673        },
674        routine_steps: resolved.steps,
675        routine_pacing: resolved.pacing,
676        routine_max_step_secs: resolved.max_step_secs,
677        routine_breath: resolved.breath,
678        chore_prompt,
679    }
680}
681
682/// Rate-limit window for repeated `UserIdle::get_time` failure warnings.
683/// One log line per 60 s is enough to surface a persistent platform-API
684/// breakage without spamming the log file once per tick.
685const USER_IDLE_WARN_INTERVAL_SECS: i64 = 60;
686
687/// Ceiling for the idle-probe back-off. Once the probe has failed enough
688/// times in a row, we settle at one attempt every five minutes — frequent
689/// enough to recover if the windowing-system extension reappears, rare
690/// enough that a permanently-missing one (e.g. X11 with no MIT-SCREEN-SAVER)
691/// no longer floods stderr with libX11 warnings.
692const IDLE_PROBE_BACKOFF_MAX_SECS: u64 = 300;
693
694/// Seconds to wait before the next idle probe after `consecutive_failures`
695/// failures in a row. Doubles from 2 s and saturates at
696/// `IDLE_PROBE_BACKOFF_MAX_SECS`; the first failure alone already stops the
697/// per-tick hammering. `consecutive_failures` is always >= 1 at the call
698/// site (it's incremented before this runs), but 0 is handled defensively
699/// and yields the same first-step delay.
700fn idle_probe_backoff_secs(consecutive_failures: u32) -> u64 {
701    let shift = consecutive_failures.saturating_sub(1).min(u32::BITS - 1);
702    2u64.saturating_mul(1u64 << shift)
703        .min(IDLE_PROBE_BACKOFF_MAX_SECS)
704}
705
706/// Per-tick gate that throttles `UserIdle::get_time()` once it starts
707/// failing. While the probe succeeds we attempt it every tick; once it
708/// fails we skip an exponentially-growing number of ticks before retrying,
709/// so a windowing system that rejects the call (X11 without
710/// MIT-SCREEN-SAVER, a denied Wayland portal) doesn't get hammered — and
711/// re-spammed — once per second.
712struct IdleProbeBackoff {
713    /// Ticks left to skip before the next probe. `0` means "probe now".
714    cooldown_remaining: u64,
715    /// Failures since the last success, driving the back-off growth.
716    consecutive_failures: u32,
717}
718
719impl IdleProbeBackoff {
720    fn new() -> Self {
721        Self {
722            cooldown_remaining: 0,
723            consecutive_failures: 0,
724        }
725    }
726
727    /// Call once per tick. Returns `true` if the run loop should probe
728    /// this tick; otherwise consumes one tick of the cooldown and returns
729    /// `false`.
730    fn should_probe(&mut self) -> bool {
731        if self.cooldown_remaining == 0 {
732            true
733        } else {
734            self.cooldown_remaining -= 1;
735            false
736        }
737    }
738
739    /// Record a successful probe: clear the failure streak and resume
740    /// probing every tick.
741    fn record_success(&mut self) {
742        self.consecutive_failures = 0;
743        self.cooldown_remaining = 0;
744    }
745
746    /// Record a failed probe and schedule the next attempt via exponential
747    /// back-off. Returns the cooldown (seconds) for the log line.
748    fn record_failure(&mut self) -> u64 {
749        self.consecutive_failures = self.consecutive_failures.saturating_add(1);
750        let secs = idle_probe_backoff_secs(self.consecutive_failures);
751        self.cooldown_remaining = secs;
752        secs
753    }
754}
755
756/// Epoch seconds (`SystemTime::UNIX_EPOCH`) at which the last UserIdle
757/// failure was logged; `0` means "never warned yet" (also the at-rest
758/// value before the scheduler boots).
759static USER_IDLE_LAST_WARN_EPOCH: AtomicI64 = AtomicI64::new(0);
760
761/// Convert `SystemTime::now()` to seconds since the Unix epoch. Returns
762/// `0` if the system clock is somehow before 1970 — same fallback as
763/// the "never warned" sentinel, which simply means the next warn fires.
764fn now_epoch_secs_for_warn() -> i64 {
765    SystemTime::now()
766        .duration_since(UNIX_EPOCH)
767        .map(|d| d.as_secs() as i64)
768        .unwrap_or(0)
769}
770
771/// Result of evaluating the per-tick suppression guards: either no
772/// guard fires, or exactly one wins and dictates the tray icon
773/// (`reason`) plus whether the event-log records a `GuardSuppress`
774/// entry (`log_as`).
775///
776/// `work_window` deliberately doesn't log — it's a scheduled silence
777/// (the user said "no breaks outside 09:00–17:00"), not an unexpected
778/// suppression worth logging once per second.
779#[derive(Debug, Clone, Copy, PartialEq, Eq)]
780pub(super) struct GuardOutcome {
781    pub reason: SuppressReason,
782    pub log_as: Option<GuardReason>,
783}
784
785/// The wall-clock decomposition the guards reason about: minute-of-day
786/// and weekday (days-since-Monday, `0`=Mon … `6`=Sun), both sampled from
787/// the single `Local::now()` taken at the top of the tick. Grouped so the
788/// two related time values travel together and `evaluate_guards` stays
789/// under the positional-argument limit.
790#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub(super) struct TickClock {
792    pub now_min: u32,
793    pub weekday: u32,
794}
795
796/// Pure decision: given the per-tick guard inputs, return which
797/// `SuppressReason` should fire (if any) and whether the run-loop
798/// should also write a `GuardSuppress` event for it.
799///
800/// Precedence (first match wins, mirroring the run-loop order):
801/// work_window → dnd → camera → video → app_pause. The run-loop is
802/// expected to short-circuit expensive checks before passing them in
803/// (e.g. only calling `dnd::is_active()` when `pause_during_dnd` is
804/// set), so the booleans here are "is the condition live right now",
805/// and the function applies the setting gates itself.
806pub(super) fn evaluate_guards(
807    s: &Settings,
808    clock: TickClock,
809    dnd_active: bool,
810    camera_active: bool,
811    video_active: bool,
812    app_pause_active: bool,
813    plugin_suppress: bool,
814) -> Option<GuardOutcome> {
815    if s.work_window_enabled
816        && !work_window_active(
817            clock.now_min,
818            s.work_start_minutes,
819            s.work_end_minutes,
820            clock.weekday,
821            s.work_days_mask,
822        )
823    {
824        return Some(GuardOutcome {
825            reason: SuppressReason::WorkWindow,
826            log_as: None,
827        });
828    }
829    if s.pause_during_dnd && dnd_active {
830        return Some(GuardOutcome {
831            reason: SuppressReason::Dnd,
832            log_as: Some(GuardReason::Dnd),
833        });
834    }
835    if s.pause_during_camera && camera_active {
836        return Some(GuardOutcome {
837            reason: SuppressReason::Camera,
838            log_as: Some(GuardReason::Camera),
839        });
840    }
841    if s.pause_during_video && video_active {
842        return Some(GuardOutcome {
843            reason: SuppressReason::Video,
844            log_as: Some(GuardReason::Video),
845        });
846    }
847    if s.app_pause_enabled && !s.app_pause_list.is_empty() && app_pause_active {
848        return Some(GuardOutcome {
849            reason: SuppressReason::AppPause,
850            log_as: Some(GuardReason::AppPause),
851        });
852    }
853    // A detector plugin voted to suppress. No settings gate: installing a
854    // detector (with consent) is the opt-in, and `plugin_suppress` is only
855    // true when an installed detector's `detect()` returned a verdict.
856    if plugin_suppress {
857        return Some(GuardOutcome {
858            reason: SuppressReason::Plugin,
859            log_as: Some(GuardReason::Plugin),
860        });
861    }
862    None
863}
864
865/// Decide whether enough time has elapsed since the last UserIdle warn
866/// to fire another one, and update the timestamp atomically if so.
867///
868/// Pure (modulo the atomic): inputs are `now` and the cell, output is
869/// just the gate. Split out so the rate-limit logic can be unit-tested
870/// without touching `log::warn!`.
871fn user_idle_warn_throttle(cell: &AtomicI64, now_epoch: i64, min_interval_secs: i64) -> bool {
872    let prev = cell.load(Ordering::Relaxed);
873    if prev != 0 && now_epoch.saturating_sub(prev) < min_interval_secs {
874        return false;
875    }
876    cell.store(now_epoch, Ordering::Relaxed);
877    true
878}
879
880/// Encoded previous lock state for the transition logger:
881///   0 = unknown / haven't seen yet (the initial value)
882///   1 = last seen as `Some(false)` (confidently unlocked)
883///   2 = last seen as `Some(true)`  (confidently locked)
884/// `Option<bool>` directly is what we want logically, but we need an
885/// atomic so the run-loop closure can mutate the previous-state
886/// across ticks without locking. `AtomicU8` is the smallest fit.
887static LOCK_STATE_PREV: AtomicU8 = AtomicU8::new(0);
888
889const LOCK_PREV_UNKNOWN: u8 = 0;
890const LOCK_PREV_UNLOCKED: u8 = 1;
891const LOCK_PREV_LOCKED: u8 = 2;
892
893fn encode_lock_state(s: Option<bool>) -> u8 {
894    match s {
895        None => LOCK_PREV_UNKNOWN,
896        Some(false) => LOCK_PREV_UNLOCKED,
897        Some(true) => LOCK_PREV_LOCKED,
898    }
899}
900
901fn decode_lock_state(b: u8) -> Option<bool> {
902    match b {
903        LOCK_PREV_UNLOCKED => Some(false),
904        LOCK_PREV_LOCKED => Some(true),
905        _ => None,
906    }
907}
908
909/// What (if anything) to log about a tick's lock-state transition.
910/// `None` is the common case: no confidently-known transition this
911/// tick. The wrapper turns the other variants into `log::info!` calls.
912#[derive(Debug, Clone, Copy, PartialEq, Eq)]
913pub(super) enum LockTransition {
914    JustLocked,
915    JustUnlocked,
916}
917
918/// Pure decision: given the previously-known lock state and the
919/// freshly-probed state, decide whether the transition is worth
920/// surfacing in the log.
921///
922/// Only transitions *between confidently-known states*
923/// (`Some(false) ↔ Some(true)`) are reportable — `None → Some(true)`
924/// is "we just got our first reading and it's locked", which we
925/// surface, but `Some(true) → None → Some(true)` is a flaky probe that
926/// must not generate a "unlocked / locked" log pair.
927pub(super) fn decide_lock_transition(
928    prev: Option<bool>,
929    next: Option<bool>,
930) -> Option<LockTransition> {
931    match (prev, next) {
932        (Some(false), Some(true)) | (None, Some(true)) => Some(LockTransition::JustLocked),
933        (Some(true), Some(false)) => Some(LockTransition::JustUnlocked),
934        // `Some(true) → None` is "we lost our signal while locked";
935        // don't claim unlock. `None → Some(false)` is "first reading,
936        // unlocked" — uninteresting. Anything→same is a no-op.
937        _ => None,
938    }
939}
940
941/// Pure decision: given the raw HID-idle seconds and an `Option<bool>`
942/// lock signal, return the idle seconds the rest of the scheduler
943/// should see. When the OS confidently reports the session as locked,
944/// promote the value past both the micro- and long-break reset
945/// thresholds so every downstream check (screen-time, suppression,
946/// typing-defer) treats the user as idle. `None` (couldn't determine
947/// lock state) leaves `raw_idle_secs` untouched — trust HID alone.
948pub(super) fn idle_secs_with_lock(raw_idle_secs: u64, locked: Option<bool>, s: &Settings) -> u64 {
949    if matches!(locked, Some(true)) {
950        raw_idle_secs
951            .max(s.micro_idle_reset_secs)
952            .max(s.long_idle_reset_secs)
953    } else {
954        raw_idle_secs
955    }
956}
957
958/// Wrapper around `idle_secs_with_lock` that also emits a single info
959/// line on each locked⇄unlocked transition, so the log shows why
960/// idle-based suppression suddenly engaged or disengaged. The
961/// previous-state tracker is tri-valued (unknown / unlocked / locked)
962/// so a flaky probe that returns `Some(true) → None → Some(true)`
963/// doesn't generate spurious "unlocked / locked" log pairs.
964fn promote_idle_for_lock(raw_idle_secs: u64, locked: Option<bool>, s: &Settings) -> u64 {
965    promote_idle_for_lock_with_cell(&LOCK_STATE_PREV, raw_idle_secs, locked, s)
966}
967
968/// Cell-parameterised variant of `promote_idle_for_lock` so the
969/// orchestration (load → decide → log → store → promote) is testable
970/// with a local atomic — mirrors `user_idle_warn_throttle` above.
971pub(super) fn promote_idle_for_lock_with_cell(
972    cell: &AtomicU8,
973    raw_idle_secs: u64,
974    locked: Option<bool>,
975    s: &Settings,
976) -> u64 {
977    let prev = decode_lock_state(cell.load(Ordering::Relaxed));
978    if let Some(transition) = decide_lock_transition(prev, locked) {
979        match transition {
980            LockTransition::JustLocked => log::info!(
981                "scheduler: session locked, treating user as idle (raw HID idle = {raw_idle_secs}s)"
982            ),
983            LockTransition::JustUnlocked => {
984                log::info!("scheduler: session unlocked, resuming HID-based idle detection")
985            }
986        }
987    }
988    // Always store the latest reading — including `None` — so a
989    // subsequent `Some(true)` after a `None` flicker doesn't re-fire
990    // the locked transition.
991    cell.store(encode_lock_state(locked), Ordering::Relaxed);
992    idle_secs_with_lock(raw_idle_secs, locked, s)
993}
994
995/// Resolve this tick's raw idle seconds, driving the probe back-off.
996/// `probe` performs the platform `UserIdle` call, yielding the idle
997/// seconds on success or a display-error string on failure. The back-off
998/// state and the throttled warning are handled here; the only thing the
999/// caller keeps platform-bound is `probe` itself, so this decision logic
1000/// is unit-testable without a windowing system.
1001///
1002/// A failed *or* skipped (in-cooldown) probe reports `None` — idle is
1003/// unknown this tick. Callers map that to `0` ("active") for screen-time
1004/// and idle-suppression (the conservative default that never silently
1005/// suppresses a break), but the typing-defer path treats `None` as "can't
1006/// tell" and fires rather than stalling every break for the cap (#67).
1007fn resolve_idle_secs(
1008    backoff: &mut IdleProbeBackoff,
1009    probe: impl FnOnce() -> Result<u64, String>,
1010) -> Option<u64> {
1011    if !backoff.should_probe() {
1012        // In a back-off cooldown: the probe is known-broken, so skip it
1013        // entirely rather than re-triggering the libX11 spam.
1014        return None;
1015    }
1016    match probe() {
1017        Ok(secs) => {
1018            backoff.record_success();
1019            Some(secs)
1020        }
1021        Err(err) => {
1022            let backoff_secs = backoff.record_failure();
1023            warn_user_idle_failure(&err, backoff_secs);
1024            None
1025        }
1026    }
1027}
1028
1029/// Idle seconds the typing-defer check should see. `Some` only when we
1030/// can affirmatively judge activity — a real HID reading this tick, or a
1031/// locked session (the user is definitely away). `None` when idle
1032/// detection is unavailable *and* the lock state is unknown/unlocked, so
1033/// the caller skips deferral rather than stalling every break for the
1034/// full cap (#67). `promoted_idle_secs` already folds in the lock
1035/// promotion, so the `Some` branch carries the value the rest of the
1036/// scheduler sees. Pure so the unavailable-on-Wayland case is testable.
1037pub(super) fn idle_for_typing_defer(
1038    reading: Option<u64>,
1039    promoted_idle_secs: u64,
1040    locked: Option<bool>,
1041) -> Option<u64> {
1042    if reading.is_some() || matches!(locked, Some(true)) {
1043        Some(promoted_idle_secs)
1044    } else {
1045        None
1046    }
1047}
1048
1049/// Surface a `UserIdle::get_time` failure to the log, at most once per
1050/// `USER_IDLE_WARN_INTERVAL_SECS`. Without this gate the production
1051/// code silently fell back to "0 = active" forever, so a broken
1052/// platform call (X11 down, macOS API change, Wayland portal denied)
1053/// would invisibly break idle suppression and screen-time tracking.
1054/// `backoff_secs` is how long the probe is now suppressed for, so the
1055/// log explains why the per-second errors stop.
1056fn warn_user_idle_failure(err: &str, backoff_secs: u64) {
1057    if user_idle_warn_throttle(
1058        &USER_IDLE_LAST_WARN_EPOCH,
1059        now_epoch_secs_for_warn(),
1060        USER_IDLE_WARN_INTERVAL_SECS,
1061    ) {
1062        log::warn!(
1063            "scheduler: idle probe failed (no windowing-system counter and \
1064             no Mutter fallback; treating user as active; backing off probe \
1065             for {backoff_secs}s): {err}"
1066        );
1067    }
1068}
1069
1070fn prebreak_message(kind: BreakKind, seconds: u64) -> (&'static str, String) {
1071    let title = match kind {
1072        BreakKind::Micro => "Micro break coming up",
1073        BreakKind::Long => "Long break coming up",
1074        BreakKind::Sleep => "Bedtime reminder coming up",
1075    };
1076    (title, format!("Starting in {seconds}s"))
1077}
1078
1079fn notify_break_coming<R: Runtime>(app: &AppHandle<R>, kind: BreakKind, seconds: u64) {
1080    let (title, body) = prebreak_message(kind, seconds);
1081    super::overlay::post_notification(app, title, body);
1082}
1083
1084fn screen_time_body(budget_minutes: u64) -> String {
1085    let hours = budget_minutes / 60;
1086    let mins = budget_minutes % 60;
1087    if hours > 0 && mins == 0 {
1088        format!(
1089            "You've been at the screen {} hour{} — time to wrap up.",
1090            hours,
1091            if hours == 1 { "" } else { "s" }
1092        )
1093    } else if hours == 0 {
1094        format!("You've been at the screen {mins} minutes — time to wrap up.")
1095    } else {
1096        format!("You've been at the screen {hours}h {mins}m — time to wrap up.")
1097    }
1098}
1099
1100fn notify_screen_time_budget<R: Runtime>(app: &AppHandle<R>, budget_minutes: u64) {
1101    super::overlay::post_notification(app, "Time to wind down", screen_time_body(budget_minutes));
1102}
1103
1104/// Pure decision: which break kinds were due (enabled and past their
1105/// interval) at this tick and so are being suppressed by a guard. Split
1106/// out from [`log_suppressions`] so the per-kind logic is unit-testable
1107/// without a `Logger`.
1108fn suppressed_kinds(s: &Settings, t: &super::timers::BreakTimers) -> Vec<BreakKind> {
1109    [
1110        (BreakKind::Micro, t.last_micro),
1111        (BreakKind::Long, t.last_long),
1112    ]
1113    .into_iter()
1114    .filter_map(|(kind, last)| {
1115        let b = s.for_kind(kind)?;
1116        (b.enabled && last.elapsed() >= Duration::from_secs(b.interval_secs)).then_some(kind)
1117    })
1118    .collect()
1119}
1120
1121fn log_suppressions(
1122    logger: &Logger,
1123    s: &Settings,
1124    t: &super::timers::BreakTimers,
1125    reason: GuardReason,
1126) {
1127    for kind in suppressed_kinds(s, t) {
1128        logger.log(EventPayload::GuardSuppress { kind, reason });
1129    }
1130}
1131
1132/// Pure decision: is a fixed-time break for `kind` due at `now_min`?
1133///
1134/// Reads the pre-parsed minutes from `s.derived` (resolved once at
1135/// settings load/update) instead of re-parsing the `"HH:MM"` strings,
1136/// and gates on the kind being enabled and in a fixed-firing schedule
1137/// mode. `Sleep` has no fixed-time schedule and is always `false`.
1138fn fixed_break_due(kind: BreakKind, s: &Settings, now_min: u32) -> bool {
1139    let Some(b) = s.for_kind(kind) else {
1140        return false;
1141    };
1142    b.enabled && s.fixed_active(kind) && b.fixed_minutes.contains(&now_min)
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::super::settings::ScheduleMode;
1148    use super::*;
1149    // Only referenced by the notification-delivery tests below, which are
1150    // gated off Windows (the Tauri mock rig is) — gate the import to match
1151    // so a Windows build doesn't trip `-D unused-imports`.
1152    #[cfg(not(target_os = "windows"))]
1153    use super::super::settings::BreakMode;
1154
1155    // Drives `deliver_scheduled_break` end to end through the
1156    // Notification delivery path — the one branch that doesn't enumerate
1157    // monitors (Tauri's MockRuntime leaves monitor APIs unimplemented, so
1158    // the overlay path can't run under test). Covers the orchestration
1159    // glue: stats lock, event build, delivery routing, hook + log. Gated
1160    // off Windows because the mock rig is.
1161    #[cfg(not(target_os = "windows"))]
1162    #[tokio::test]
1163    #[allow(clippy::field_reassign_with_default)]
1164    async fn deliver_scheduled_break_notification_path_runs_glue() {
1165        use crate::test_support::test_scheduler;
1166        use tauri::test::{mock_builder, mock_context, noop_assets};
1167        use tauri::Manager;
1168
1169        let mut settings = Settings::default();
1170        settings.micro_break_mode = BreakMode::Notification;
1171        let (_dir, sched) = test_scheduler(settings.clone());
1172
1173        // No notification plugin: the cfg(test) `post_notification` is a
1174        // no-op, so the delivery path must run without it. If the OS-posting
1175        // body ever leaks into the test build, `app.notification()` panics
1176        // here — a tripwire against reintroducing real-notification spam.
1177        let app = mock_builder()
1178            .build(mock_context(noop_assets()))
1179            .expect("mock app builds");
1180        app.manage(sched.clone());
1181
1182        let delivery =
1183            deliver_scheduled_break(app.handle(), &sched, &settings, BreakKind::Micro).await;
1184
1185        assert_eq!(delivery, BreakDelivery::Notification);
1186        // Notification delivery must not stash an overlay break.
1187        assert!(sched.current_break.lock().unwrap().is_none());
1188    }
1189
1190    // Drives `deliver_sleep_break` end to end. `select_overlay_monitors`
1191    // returns empty under `#[cfg(test)]` so `fire_break` stashes
1192    // `current_break` and returns without enumerating real monitors. Gated off
1193    // Windows for the same mock-rig reason as the other MockRuntime tests.
1194    #[cfg(not(target_os = "windows"))]
1195    #[tokio::test]
1196    async fn deliver_sleep_break_sets_current_break() {
1197        use crate::test_support::test_scheduler;
1198        use tauri::test::{mock_builder, mock_context, noop_assets};
1199        use tauri::Manager;
1200
1201        let s = Settings::default();
1202        let (_dir, sched) = test_scheduler(s.clone());
1203
1204        let app = mock_builder()
1205            .build(mock_context(noop_assets()))
1206            .expect("mock app builds");
1207        app.manage(sched.clone());
1208
1209        deliver_sleep_break(app.handle(), &sched, &s, 0.0);
1210
1211        assert!(sched.current_break.lock().unwrap().is_some());
1212    }
1213
1214    // Drives `fire_scheduled_break` end to end: the notification delivery
1215    // plus the post-fire timer bookkeeping it applies under the lock.
1216    // Gated off Windows for the same mock-rig reason as the glue test above.
1217    #[cfg(not(target_os = "windows"))]
1218    #[tokio::test]
1219    #[allow(clippy::field_reassign_with_default)]
1220    async fn fire_scheduled_break_delivers_then_records_timers() {
1221        use crate::test_support::test_scheduler;
1222        use tauri::test::{mock_builder, mock_context, noop_assets};
1223        use tauri::Manager;
1224
1225        let mut settings = Settings::default();
1226        settings.micro_break_mode = BreakMode::Notification;
1227        let (_dir, sched) = test_scheduler(settings.clone());
1228        {
1229            let mut t = sched.timers.lock().await;
1230            t.micro_warned = true;
1231            t.micro_deferred_since = Some(Instant::now());
1232        }
1233
1234        let app = mock_builder()
1235            .build(mock_context(noop_assets()))
1236            .expect("mock app builds");
1237        app.manage(sched.clone());
1238
1239        let before = Instant::now();
1240        fire_scheduled_break(
1241            app.handle(),
1242            &sched,
1243            &settings,
1244            BreakKind::Micro,
1245            Some(("2026-06-02".into(), 600)),
1246        )
1247        .await;
1248
1249        let t = sched.timers.lock().await;
1250        assert!(t.last_micro >= before);
1251        assert!(!t.micro_warned);
1252        assert_eq!(t.micro_deferred_since, None);
1253        assert_eq!(t.last_micro_fixed_fire, Some(("2026-06-02".into(), 600)));
1254        // Notification delivery must not stash an active break.
1255        assert_eq!(t.active_break, None);
1256    }
1257
1258    #[test]
1259    fn scheduled_break_event_long_draws_long_fields() {
1260        let mut s = Settings::default();
1261        s.rebuild_derived();
1262        let e = scheduled_break_event(
1263            BreakKind::Long,
1264            &s,
1265            0.5,
1266            Some("Water the plants".to_string()),
1267        );
1268        assert_eq!(e.kind, BreakKind::Long);
1269        assert_eq!(e.duration_secs, s.long_duration_secs);
1270        assert_eq!(e.manual_finish, s.long_manual_finish);
1271        assert_eq!(e.hints, s.effective_hints(BreakKind::Long));
1272        assert!(!e.hints.is_empty(), "default long hints are non-empty");
1273        // break_health is enabled by default → live intensity passes through.
1274        assert_eq!(e.health_intensity, 0.5);
1275        // The resolved chore nudge is carried straight onto the event.
1276        assert_eq!(e.chore_prompt.as_deref(), Some("Water the plants"));
1277    }
1278
1279    #[test]
1280    fn scheduled_break_event_micro_draws_micro_fields() {
1281        let mut s = Settings::default();
1282        s.rebuild_derived();
1283        let e = scheduled_break_event(BreakKind::Micro, &s, 0.5, None);
1284        assert_eq!(e.kind, BreakKind::Micro);
1285        assert_eq!(e.duration_secs, s.micro_duration_secs);
1286        assert_eq!(e.manual_finish, s.micro_manual_finish);
1287        assert_eq!(e.hints, s.effective_hints(BreakKind::Micro));
1288        assert!(!e.hints.is_empty(), "default micro hints are non-empty");
1289        // Micro breaks never carry a chore nudge.
1290        assert_eq!(e.chore_prompt, None);
1291    }
1292
1293    #[test]
1294    #[should_panic(expected = "sleep breaks use the bedtime fire path")]
1295    fn scheduled_break_event_rejects_sleep() {
1296        // Sleep is delivered through the bedtime path, never this helper;
1297        // the invariant is enforced with a panic and asserted here.
1298        let s = Settings::default();
1299        let _ = scheduled_break_event(BreakKind::Sleep, &s, 0.0, None);
1300    }
1301
1302    #[test]
1303    #[allow(clippy::field_reassign_with_default)]
1304    fn sleep_break_event_has_empty_routine_fields() {
1305        let s = Settings::default();
1306        let e = sleep_break_event(&s, 0.0);
1307        assert!(e.routine_steps.is_empty());
1308        assert_eq!(e.routine_pacing, None);
1309        assert_eq!(e.routine_max_step_secs, None);
1310        assert_eq!(e.chore_prompt, None);
1311        assert_eq!(e.kind, BreakKind::Sleep);
1312        // Cover the break_health_enabled true branch.
1313        let mut s2 = Settings::default();
1314        s2.break_health_enabled = true;
1315        let e2 = sleep_break_event(&s2, 0.75);
1316        assert_eq!(e2.health_intensity, 0.75);
1317        // Cover the false branch: health disabled → intensity zeroed.
1318        let mut s3 = Settings::default();
1319        s3.break_health_enabled = false;
1320        let e3 = sleep_break_event(&s3, 0.75);
1321        assert_eq!(e3.health_intensity, 0.0);
1322    }
1323
1324    #[test]
1325    fn prebreak_message_titles_per_kind() {
1326        assert_eq!(
1327            prebreak_message(BreakKind::Micro, 30).0,
1328            "Micro break coming up"
1329        );
1330        assert_eq!(
1331            prebreak_message(BreakKind::Long, 30).0,
1332            "Long break coming up"
1333        );
1334        assert_eq!(
1335            prebreak_message(BreakKind::Sleep, 30).0,
1336            "Bedtime reminder coming up"
1337        );
1338        assert_eq!(prebreak_message(BreakKind::Micro, 45).1, "Starting in 45s");
1339    }
1340
1341    #[test]
1342    fn screen_time_body_formats_each_bucket() {
1343        assert_eq!(
1344            screen_time_body(45),
1345            "You've been at the screen 45 minutes — time to wrap up."
1346        );
1347        assert_eq!(
1348            screen_time_body(60),
1349            "You've been at the screen 1 hour — time to wrap up."
1350        );
1351        assert_eq!(
1352            screen_time_body(120),
1353            "You've been at the screen 2 hours — time to wrap up."
1354        );
1355        assert_eq!(
1356            screen_time_body(90),
1357            "You've been at the screen 1h 30m — time to wrap up."
1358        );
1359    }
1360
1361    // The notify_* wrappers are thin glue over post_notification, which is a
1362    // no-op under cfg(test). Driving them with a mock app that has no
1363    // notification plugin proves they run the delivery glue without touching
1364    // the OS — and would panic (no plugin) if the real show path ever leaked
1365    // back into a test build.
1366    #[cfg(not(target_os = "windows"))]
1367    #[test]
1368    fn notify_wrappers_do_not_touch_os_under_test() {
1369        use tauri::test::{mock_builder, mock_context, noop_assets};
1370
1371        let app = mock_builder()
1372            .build(mock_context(noop_assets()))
1373            .expect("mock app builds");
1374        notify_break_coming(app.handle(), BreakKind::Micro, 30);
1375        notify_break_coming(app.handle(), BreakKind::Long, 60);
1376        notify_break_coming(app.handle(), BreakKind::Sleep, 90);
1377        notify_screen_time_budget(app.handle(), 90);
1378    }
1379
1380    #[test]
1381    #[allow(clippy::field_reassign_with_default)]
1382    fn scheduled_break_event_health_gate_zeroes_intensity_when_disabled() {
1383        let mut s = Settings::default();
1384        s.break_health_enabled = false;
1385        let e = scheduled_break_event(BreakKind::Long, &s, 0.42, None);
1386        assert_eq!(e.health_intensity, 0.0);
1387    }
1388
1389    #[test]
1390    #[allow(clippy::field_reassign_with_default)]
1391    fn scheduled_break_event_strict_mode_forces_enforceable_and_no_postpone() {
1392        let mut s = Settings::default();
1393        s.strict_mode = true;
1394        s.postpone_enabled = true;
1395        let e = scheduled_break_event(BreakKind::Long, &s, 0.0, None);
1396        assert!(e.enforceable, "strict mode forces enforceable");
1397        assert!(!e.postpone_available, "strict mode disables postpone");
1398    }
1399
1400    #[test]
1401    #[allow(clippy::field_reassign_with_default)]
1402    fn scheduled_break_event_honours_per_kind_postpone() {
1403        let mut s = Settings::default();
1404        s.postpone_enabled = true;
1405        s.micro_postpone_enabled = false;
1406        s.long_postpone_enabled = true;
1407
1408        let micro = scheduled_break_event(BreakKind::Micro, &s, 0.0, None);
1409        assert!(
1410            !micro.postpone_available,
1411            "micro postpone disabled per-kind"
1412        );
1413
1414        let long = scheduled_break_event(BreakKind::Long, &s, 0.0, None);
1415        assert!(long.postpone_available, "long postpone left on per-kind");
1416    }
1417
1418    #[test]
1419    #[allow(clippy::field_reassign_with_default)]
1420    fn scheduled_break_event_honours_per_kind_skip() {
1421        let mut s = Settings::default();
1422        s.micro_skip_enabled = false;
1423        s.long_skip_enabled = true;
1424
1425        let micro = scheduled_break_event(BreakKind::Micro, &s, 0.0, None);
1426        assert!(!micro.skip_available, "micro skip disabled per-kind");
1427
1428        let long = scheduled_break_event(BreakKind::Long, &s, 0.0, None);
1429        assert!(long.skip_available, "long skip left on per-kind");
1430    }
1431
1432    #[test]
1433    fn import_pending_returns_false_when_flag_clear() {
1434        let flag = AtomicBool::new(false);
1435        assert!(!import_pending(&flag));
1436    }
1437
1438    #[test]
1439    fn import_pending_returns_true_when_flag_set() {
1440        let flag = AtomicBool::new(true);
1441        assert!(import_pending(&flag));
1442    }
1443
1444    #[test]
1445    fn resumed_after_gap_true_when_gap_exceeds_threshold() {
1446        let prev = SystemTime::now();
1447        let now = prev + Duration::from_secs(8 * 3600);
1448        assert!(resumed_after_gap(prev, now, SUSPEND_GAP_THRESHOLD));
1449    }
1450
1451    #[test]
1452    fn resumed_after_gap_false_for_a_normal_tick() {
1453        let prev = SystemTime::now();
1454        let now = prev + Duration::from_secs(1);
1455        assert!(!resumed_after_gap(prev, now, SUSPEND_GAP_THRESHOLD));
1456    }
1457
1458    #[test]
1459    fn resumed_after_gap_false_on_backwards_clock_step() {
1460        // duration_since errors when now precedes prev; that must read as
1461        // "not resumed" rather than panic.
1462        let now = SystemTime::now();
1463        let prev = now + Duration::from_secs(120);
1464        assert!(!resumed_after_gap(prev, now, SUSPEND_GAP_THRESHOLD));
1465    }
1466
1467    #[test]
1468    fn resumed_after_gap_is_inclusive_at_threshold() {
1469        let prev = SystemTime::now();
1470        let now = prev + SUSPEND_GAP_THRESHOLD;
1471        assert!(resumed_after_gap(prev, now, SUSPEND_GAP_THRESHOLD));
1472    }
1473
1474    // ----- fixed_break_due: cached fixed-time firing behaves like the
1475    //       old per-tick parse, but reads pre-parsed minutes -----
1476
1477    fn settings_with_fixed(kind: BreakKind, mode: ScheduleMode, times: Vec<&str>) -> Settings {
1478        // Set the requested kind's fixed schedule. We touch only the
1479        // matching pair so each test asserts the per-kind cache in
1480        // isolation; `kind == Micro` leaves the long fields default and
1481        // vice-versa. (No `Sleep` arm — `fixed_break_due` already returns
1482        // `false` for it and `fixed_break_due_sleep_never_fires` covers
1483        // that, so the fixture never needs to build a Sleep case.)
1484        let is_micro = matches!(kind, BreakKind::Micro);
1485        let parsed: Vec<String> = times.into_iter().map(String::from).collect();
1486        let mut s = Settings::default();
1487        if is_micro {
1488            s.micro_schedule_mode = mode;
1489            s.micro_fixed_times = parsed;
1490        } else {
1491            s.long_schedule_mode = mode;
1492            s.long_fixed_times = parsed;
1493        }
1494        s.rebuild_derived();
1495        s
1496    }
1497
1498    #[test]
1499    fn fixed_break_due_fires_at_cached_minute() {
1500        let s = settings_with_fixed(
1501            BreakKind::Micro,
1502            ScheduleMode::Fixed,
1503            vec!["09:00", "13:30"],
1504        );
1505        assert!(fixed_break_due(BreakKind::Micro, &s, 540));
1506        assert!(fixed_break_due(BreakKind::Micro, &s, 810));
1507        assert!(!fixed_break_due(BreakKind::Micro, &s, 541));
1508    }
1509
1510    #[test]
1511    fn fixed_break_due_respects_schedule_mode_and_enabled() {
1512        // "interval" mode → fixed times never fire even though they parse.
1513        let s = settings_with_fixed(BreakKind::Long, ScheduleMode::Interval, vec!["09:00"]);
1514        assert!(!fixed_break_due(BreakKind::Long, &s, 540));
1515
1516        // Disabled kind → no fire.
1517        let mut s = settings_with_fixed(BreakKind::Long, ScheduleMode::Fixed, vec!["09:00"]);
1518        s.long_enabled = false;
1519        assert!(!fixed_break_due(BreakKind::Long, &s, 540));
1520    }
1521
1522    #[test]
1523    fn fixed_break_due_both_mode_fires() {
1524        let s = settings_with_fixed(BreakKind::Micro, ScheduleMode::Both, vec!["07:05"]);
1525        assert!(fixed_break_due(BreakKind::Micro, &s, 425));
1526    }
1527
1528    #[test]
1529    fn fixed_break_due_sleep_never_fires() {
1530        let s = Settings::default();
1531        assert!(!fixed_break_due(BreakKind::Sleep, &s, 0));
1532    }
1533
1534    #[test]
1535    fn fixed_break_due_tracks_cache_rebuild() {
1536        // Editing the times and rebuilding must change which minute fires.
1537        let mut s = settings_with_fixed(BreakKind::Micro, ScheduleMode::Fixed, vec!["08:00"]);
1538        assert!(fixed_break_due(BreakKind::Micro, &s, 480));
1539        s.micro_fixed_times = vec!["15:45".into()];
1540        s.rebuild_derived();
1541        assert!(!fixed_break_due(BreakKind::Micro, &s, 480));
1542        assert!(fixed_break_due(BreakKind::Micro, &s, 945));
1543    }
1544
1545    // Fix #1: anchoring `last_app_refresh` 60s before boot used to be
1546    // `Instant::now() - Duration::from_secs(60)`, which panics if the
1547    // monotonic clock is younger than 60s (cold-boot Windows runners).
1548    #[test]
1549    fn boot_anchor_never_panics_when_clock_is_young() {
1550        // Mimic the run-loop initialiser; if the underflow protection is
1551        // missing the `.checked_sub(...).unwrap_or_else(now)` chain
1552        // returns a valid `Instant` instead of panicking.
1553        let anchor = Instant::now()
1554            .checked_sub(Duration::from_secs(60))
1555            .unwrap_or_else(Instant::now);
1556        // Anchor must not be after "now" — either it is 60s in the past
1557        // (clock old enough) or it equals "now" (clock too young).
1558        let now = Instant::now();
1559        assert!(anchor <= now);
1560    }
1561
1562    // Fix #5: `warn_user_idle_failure` must surface platform errors but
1563    // only at most once per `USER_IDLE_WARN_INTERVAL_SECS`. The pure
1564    // throttle helper is the actual decision gate.
1565    #[test]
1566    fn user_idle_warn_throttle_fires_first_warning() {
1567        let cell = AtomicI64::new(0);
1568        assert!(user_idle_warn_throttle(&cell, 1000, 60));
1569        assert_eq!(cell.load(Ordering::Relaxed), 1000);
1570    }
1571
1572    #[test]
1573    fn user_idle_warn_throttle_suppresses_within_window() {
1574        let cell = AtomicI64::new(1000);
1575        assert!(!user_idle_warn_throttle(&cell, 1030, 60));
1576        assert!(!user_idle_warn_throttle(&cell, 1059, 60));
1577        // Cell unchanged when throttled.
1578        assert_eq!(cell.load(Ordering::Relaxed), 1000);
1579    }
1580
1581    #[test]
1582    fn user_idle_warn_throttle_refires_after_window() {
1583        let cell = AtomicI64::new(1000);
1584        assert!(user_idle_warn_throttle(&cell, 1060, 60));
1585        assert_eq!(cell.load(Ordering::Relaxed), 1060);
1586        // Subsequent within new window suppressed.
1587        assert!(!user_idle_warn_throttle(&cell, 1075, 60));
1588    }
1589
1590    #[test]
1591    fn user_idle_warn_throttle_handles_clock_jumping_backwards() {
1592        // System clock going backwards (NTP correction) shouldn't
1593        // deadlock the throttle — saturating_sub returns 0, which is
1594        // < min_interval, so we suppress and don't update the cell.
1595        let cell = AtomicI64::new(2000);
1596        assert!(!user_idle_warn_throttle(&cell, 1500, 60));
1597        assert_eq!(cell.load(Ordering::Relaxed), 2000);
1598    }
1599
1600    // ----- idle_probe_backoff_secs / IdleProbeBackoff: stop hammering a broken probe -----
1601
1602    #[test]
1603    fn idle_probe_backoff_secs_doubles_from_two() {
1604        assert_eq!(idle_probe_backoff_secs(1), 2);
1605        assert_eq!(idle_probe_backoff_secs(2), 4);
1606        assert_eq!(idle_probe_backoff_secs(3), 8);
1607        assert_eq!(idle_probe_backoff_secs(4), 16);
1608    }
1609
1610    #[test]
1611    fn idle_probe_backoff_secs_saturates_at_max() {
1612        // Far enough up the curve to be clamped, and the extreme value
1613        // must not overflow the shift or the multiply.
1614        assert_eq!(idle_probe_backoff_secs(20), IDLE_PROBE_BACKOFF_MAX_SECS);
1615        assert_eq!(
1616            idle_probe_backoff_secs(u32::MAX),
1617            IDLE_PROBE_BACKOFF_MAX_SECS
1618        );
1619    }
1620
1621    #[test]
1622    fn idle_probe_backoff_secs_handles_zero_defensively() {
1623        // Never called with 0 in production (failures are incremented
1624        // first), but it must not underflow the shift.
1625        assert_eq!(idle_probe_backoff_secs(0), 2);
1626    }
1627
1628    #[test]
1629    fn idle_backoff_probes_every_tick_while_healthy() {
1630        let mut b = IdleProbeBackoff::new();
1631        for _ in 0..5 {
1632            assert!(b.should_probe());
1633            b.record_success();
1634        }
1635    }
1636
1637    #[test]
1638    fn idle_backoff_skips_ticks_after_failure_then_retries() {
1639        let mut b = IdleProbeBackoff::new();
1640        // First tick probes and fails → 2s cooldown.
1641        assert!(b.should_probe());
1642        assert_eq!(b.record_failure(), 2);
1643        // Next two ticks are skipped while the cooldown drains.
1644        assert!(!b.should_probe());
1645        assert!(!b.should_probe());
1646        // Cooldown exhausted: probe again.
1647        assert!(b.should_probe());
1648    }
1649
1650    #[test]
1651    fn resolve_idle_secs_returns_probe_value_on_success() {
1652        let mut b = IdleProbeBackoff::new();
1653        assert_eq!(resolve_idle_secs(&mut b, || Ok(42)), Some(42));
1654        // Success keeps probing every tick.
1655        assert!(b.should_probe());
1656    }
1657
1658    #[test]
1659    fn resolve_idle_secs_failure_reports_unknown_and_backs_off() {
1660        let mut b = IdleProbeBackoff::new();
1661        // A failing probe reports `None` (idle unknown) and arms the
1662        // cooldown, so the next tick skips the probe.
1663        assert_eq!(
1664            resolve_idle_secs(&mut b, || Err("no screensaver".into())),
1665            None
1666        );
1667        assert!(!b.should_probe());
1668    }
1669
1670    #[test]
1671    fn resolve_idle_secs_skips_probe_during_cooldown() {
1672        let mut b = IdleProbeBackoff::new();
1673        // Arm a cooldown via one failure...
1674        assert_eq!(resolve_idle_secs(&mut b, || Err("boom".into())), None);
1675        // ...then the next call must NOT invoke the probe at all.
1676        let result = resolve_idle_secs(&mut b, || panic!("probe must not run in cooldown"));
1677        assert_eq!(result, None);
1678    }
1679
1680    #[test]
1681    fn idle_for_typing_defer_passes_real_reading_through() {
1682        // A successful probe (even 0 = active) is a usable signal.
1683        assert_eq!(idle_for_typing_defer(Some(0), 0, Some(false)), Some(0));
1684        assert_eq!(idle_for_typing_defer(Some(5), 5, None), Some(5));
1685    }
1686
1687    #[test]
1688    fn idle_for_typing_defer_unknown_idle_is_none() {
1689        // Wayland (#67): no reading and not locked → can't judge typing,
1690        // so the defer check must see `None` and fire the break.
1691        assert_eq!(idle_for_typing_defer(None, 0, None), None);
1692        assert_eq!(idle_for_typing_defer(None, 0, Some(false)), None);
1693    }
1694
1695    #[test]
1696    fn idle_for_typing_defer_locked_session_is_known_away() {
1697        // No HID reading, but the session is locked: the user is
1698        // definitely away, so surface the lock-promoted idle value.
1699        assert_eq!(idle_for_typing_defer(None, 900, Some(true)), Some(900));
1700    }
1701
1702    #[test]
1703    fn idle_backoff_grows_then_success_resets() {
1704        let mut b = IdleProbeBackoff::new();
1705        assert!(b.should_probe());
1706        assert_eq!(b.record_failure(), 2);
1707        // Drain the 2s cooldown.
1708        assert!(!b.should_probe());
1709        assert!(!b.should_probe());
1710        assert!(b.should_probe());
1711        // Second consecutive failure backs off further.
1712        assert_eq!(b.record_failure(), 4);
1713        // A recovery resets the streak so we probe every tick again.
1714        assert!(!b.should_probe());
1715        // Pretend the cooldown drained and the probe finally succeeded.
1716        b.record_success();
1717        assert!(b.should_probe());
1718        // And a fresh failure starts the curve over at the first step.
1719        assert_eq!(b.record_failure(), 2);
1720    }
1721
1722    // ----- idle_secs_with_lock: lock screen promotes HID-idle past thresholds -----
1723
1724    fn settings_with_idle_thresholds(micro: u64, long: u64) -> Settings {
1725        Settings {
1726            micro_idle_reset_secs: micro,
1727            long_idle_reset_secs: long,
1728            ..Settings::default()
1729        }
1730    }
1731
1732    #[test]
1733    fn idle_secs_with_lock_passthrough_when_unlocked() {
1734        // `Some(false)` is the confidently-unlocked case — must not
1735        // touch the raw HID value, regardless of how large or small.
1736        let s = settings_with_idle_thresholds(120, 300);
1737        assert_eq!(idle_secs_with_lock(0, Some(false), &s), 0);
1738        assert_eq!(idle_secs_with_lock(45, Some(false), &s), 45);
1739        assert_eq!(idle_secs_with_lock(9999, Some(false), &s), 9999);
1740    }
1741
1742    #[test]
1743    fn idle_secs_with_lock_passthrough_when_unknown() {
1744        // `None` means the platform probe couldn't decide. We must
1745        // fall back to HID-only behaviour rather than guess.
1746        let s = settings_with_idle_thresholds(120, 300);
1747        assert_eq!(idle_secs_with_lock(0, None, &s), 0);
1748        assert_eq!(idle_secs_with_lock(60, None, &s), 60);
1749    }
1750
1751    #[test]
1752    fn idle_secs_with_lock_promotes_to_both_thresholds_when_locked() {
1753        // A raw HID idle of zero (the caffeinate-u / stuck-input
1754        // pathology) must end up >= both thresholds so the suppression
1755        // gate at line ~341 trips for both micro and long.
1756        let s = settings_with_idle_thresholds(120, 300);
1757        let promoted = idle_secs_with_lock(0, Some(true), &s);
1758        assert!(promoted >= s.micro_idle_reset_secs);
1759        assert!(promoted >= s.long_idle_reset_secs);
1760        assert_eq!(promoted, 300);
1761    }
1762
1763    #[test]
1764    fn idle_secs_with_lock_preserves_larger_raw_value() {
1765        // If the HID counter is already above both thresholds (user
1766        // was genuinely idle AND the screen happens to be locked), the
1767        // promotion must not shrink it — that would mis-report screen
1768        // time and reset deferral state incorrectly.
1769        let s = settings_with_idle_thresholds(120, 300);
1770        assert_eq!(idle_secs_with_lock(900, Some(true), &s), 900);
1771    }
1772
1773    #[test]
1774    fn idle_secs_with_lock_handles_asymmetric_thresholds() {
1775        // Long-break threshold larger than micro: must promote to the
1776        // larger of the two so both gates trip.
1777        let s = settings_with_idle_thresholds(60, 600);
1778        assert_eq!(idle_secs_with_lock(0, Some(true), &s), 600);
1779        // And vice-versa.
1780        let s = settings_with_idle_thresholds(600, 60);
1781        assert_eq!(idle_secs_with_lock(0, Some(true), &s), 600);
1782    }
1783
1784    // ----- decide_lock_transition: only log between confidently-known states -----
1785
1786    #[test]
1787    fn lock_transition_first_reading_locked_logs_locked() {
1788        // Cold-start with a locked screen: the user wasn't here when
1789        // we booted; we should log that we're treating them as idle.
1790        assert_eq!(
1791            decide_lock_transition(None, Some(true)),
1792            Some(LockTransition::JustLocked),
1793        );
1794    }
1795
1796    #[test]
1797    fn lock_transition_first_reading_unlocked_is_silent() {
1798        // Cold-start with an unlocked screen is the happy path —
1799        // logging "session unlocked" with no prior context would be
1800        // confusing noise in the journal.
1801        assert_eq!(decide_lock_transition(None, Some(false)), None);
1802    }
1803
1804    #[test]
1805    fn lock_transition_unlocked_to_locked_logs_locked() {
1806        assert_eq!(
1807            decide_lock_transition(Some(false), Some(true)),
1808            Some(LockTransition::JustLocked),
1809        );
1810    }
1811
1812    #[test]
1813    fn lock_transition_locked_to_unlocked_logs_unlocked() {
1814        assert_eq!(
1815            decide_lock_transition(Some(true), Some(false)),
1816            Some(LockTransition::JustUnlocked),
1817        );
1818    }
1819
1820    #[test]
1821    fn lock_transition_same_state_is_silent() {
1822        // Repeated probes returning the same state must not log every
1823        // tick (the whole point of the transition tracker).
1824        assert_eq!(decide_lock_transition(Some(true), Some(true)), None);
1825        assert_eq!(decide_lock_transition(Some(false), Some(false)), None);
1826        assert_eq!(decide_lock_transition(None, None), None);
1827    }
1828
1829    #[test]
1830    fn lock_transition_loss_of_signal_while_locked_is_silent() {
1831        // The regression from the original review: probe returns
1832        // `Some(true) → None → Some(true)` while the user is locked
1833        // the whole time. The `Some(true) → None` step must NOT log
1834        // "unlocked", because we haven't observed an unlock.
1835        assert_eq!(decide_lock_transition(Some(true), None), None);
1836    }
1837
1838    #[test]
1839    fn lock_transition_loss_of_signal_while_unlocked_is_silent() {
1840        // Symmetric: probe goes flaky while unlocked. No log.
1841        assert_eq!(decide_lock_transition(Some(false), None), None);
1842    }
1843
1844    #[test]
1845    fn lock_transition_recovery_after_flicker_does_not_double_log() {
1846        // The full flake pattern: locked → unknown → still locked.
1847        // The transition tracker stores the latest reading (including
1848        // `None`) so the second transition we evaluate is `None →
1849        // Some(true)` — which we DO log, because that's the only way
1850        // we'd ever surface the lock state if the very first probe
1851        // was a transient failure. Symmetric for the unlocked path.
1852        assert_eq!(decode_lock_state(encode_lock_state(None)), None);
1853        assert_eq!(
1854            decode_lock_state(encode_lock_state(Some(false))),
1855            Some(false)
1856        );
1857        assert_eq!(decode_lock_state(encode_lock_state(Some(true))), Some(true));
1858    }
1859
1860    #[test]
1861    fn decode_lock_state_rejects_unknown_values() {
1862        // Defensive: a stored byte we don't recognise (impossible
1863        // unless someone adds a third concrete state) should fall back
1864        // to `None` (unknown) rather than silently mis-decode.
1865        assert_eq!(decode_lock_state(0), None);
1866        assert_eq!(decode_lock_state(99), None);
1867    }
1868
1869    // ----- promote_idle_for_lock_with_cell: orchestration over a local cell -----
1870
1871    #[test]
1872    fn promote_with_cell_first_locked_reading_promotes_and_remembers() {
1873        // Cold start → confidently locked. The promoted idle must
1874        // clear both thresholds, and the cell must record the new
1875        // state so the next tick doesn't re-fire the transition.
1876        let s = settings_with_idle_thresholds(120, 300);
1877        let cell = AtomicU8::new(LOCK_PREV_UNKNOWN);
1878        let promoted = promote_idle_for_lock_with_cell(&cell, 0, Some(true), &s);
1879        assert_eq!(promoted, 300);
1880        assert_eq!(cell.load(Ordering::Relaxed), LOCK_PREV_LOCKED);
1881    }
1882
1883    #[test]
1884    fn promote_with_cell_unlocked_reading_is_passthrough_and_recorded() {
1885        let s = settings_with_idle_thresholds(120, 300);
1886        let cell = AtomicU8::new(LOCK_PREV_UNKNOWN);
1887        let promoted = promote_idle_for_lock_with_cell(&cell, 42, Some(false), &s);
1888        assert_eq!(promoted, 42);
1889        assert_eq!(cell.load(Ordering::Relaxed), LOCK_PREV_UNLOCKED);
1890    }
1891
1892    #[test]
1893    fn promote_with_cell_unlock_transition_passes_raw_value_through() {
1894        // Previously locked, now confidently unlocked: emits the
1895        // "session unlocked" log line and returns the raw HID value
1896        // untouched.
1897        let s = settings_with_idle_thresholds(120, 300);
1898        let cell = AtomicU8::new(LOCK_PREV_LOCKED);
1899        let promoted = promote_idle_for_lock_with_cell(&cell, 7, Some(false), &s);
1900        assert_eq!(promoted, 7);
1901        assert_eq!(cell.load(Ordering::Relaxed), LOCK_PREV_UNLOCKED);
1902    }
1903
1904    #[test]
1905    fn promote_with_cell_none_after_locked_keeps_promoting_but_records_unknown() {
1906        // Probe flickers to `None` while the user is still locked:
1907        // the transition decision is silent (we don't claim unlock),
1908        // the cell stores `LOCK_PREV_UNKNOWN`, and the idle value is
1909        // the raw HID seconds (we have no positive lock signal this
1910        // tick).
1911        let s = settings_with_idle_thresholds(120, 300);
1912        let cell = AtomicU8::new(LOCK_PREV_LOCKED);
1913        let promoted = promote_idle_for_lock_with_cell(&cell, 5, None, &s);
1914        assert_eq!(promoted, 5);
1915        assert_eq!(cell.load(Ordering::Relaxed), LOCK_PREV_UNKNOWN);
1916    }
1917
1918    #[test]
1919    fn promote_idle_for_lock_thin_wrapper_routes_through_global_cell() {
1920        // The production wrapper just forwards to
1921        // `_with_cell` against the static `LOCK_STATE_PREV`. We
1922        // can't assert the global state without racing parallel
1923        // tests, but a smoke call confirms the wrapper actually
1924        // executes and returns the expected promotion for the
1925        // input combination — which is all the static-bound
1926        // wrapper itself can be tested for.
1927        let s = settings_with_idle_thresholds(120, 300);
1928        // Unlocked input is hermetic: regardless of whatever
1929        // earlier test left in the global, the result is just
1930        // the raw HID value passed through.
1931        assert_eq!(promote_idle_for_lock(11, Some(false), &s), 11);
1932    }
1933
1934    #[test]
1935    fn promote_with_cell_repeated_locked_reading_does_not_change_state() {
1936        // Already locked, still locked: cell stays at LOCK_PREV_LOCKED
1937        // and the promoted value still clears both thresholds.
1938        let s = settings_with_idle_thresholds(120, 300);
1939        let cell = AtomicU8::new(LOCK_PREV_LOCKED);
1940        let promoted = promote_idle_for_lock_with_cell(&cell, 0, Some(true), &s);
1941        assert_eq!(promoted, 300);
1942        assert_eq!(cell.load(Ordering::Relaxed), LOCK_PREV_LOCKED);
1943    }
1944
1945    // ----- evaluate_guards: pure per-tick suppression decision -----
1946
1947    fn settings_for_guards(
1948        work_window: bool,
1949        dnd: bool,
1950        camera: bool,
1951        video: bool,
1952        app_pause_with_targets: bool,
1953    ) -> Settings {
1954        Settings {
1955            work_window_enabled: work_window,
1956            work_start_minutes: 9 * 60,
1957            work_end_minutes: 17 * 60,
1958            pause_during_dnd: dnd,
1959            pause_during_camera: camera,
1960            pause_during_video: video,
1961            app_pause_enabled: app_pause_with_targets,
1962            app_pause_list: if app_pause_with_targets {
1963                vec!["zoom".to_string()]
1964            } else {
1965                Vec::new()
1966            },
1967            ..Settings::default()
1968        }
1969    }
1970
1971    const INSIDE_WORK_WINDOW: u32 = 10 * 60;
1972    const OUTSIDE_WORK_WINDOW: u32 = 20 * 60;
1973    // Default `work_days_mask` enables every day, so the weekday passed to
1974    // these time-window tests doesn't change their outcome; the day-gating
1975    // behaviour gets its own dedicated tests below.
1976    const ANY_WEEKDAY: u32 = 0;
1977
1978    #[test]
1979    fn evaluate_guards_returns_none_when_all_off() {
1980        let s = settings_for_guards(false, false, false, false, false);
1981        assert!(evaluate_guards(
1982            &s,
1983            TickClock {
1984                now_min: INSIDE_WORK_WINDOW,
1985                weekday: ANY_WEEKDAY
1986            },
1987            true,
1988            true,
1989            true,
1990            true,
1991            false
1992        )
1993        .is_none());
1994    }
1995
1996    #[test]
1997    fn evaluate_guards_work_window_inside_returns_none() {
1998        // work_window_enabled with a current minute inside [start,end)
1999        // is the happy path — no suppression.
2000        let s = settings_for_guards(true, false, false, false, false);
2001        assert!(evaluate_guards(
2002            &s,
2003            TickClock {
2004                now_min: INSIDE_WORK_WINDOW,
2005                weekday: ANY_WEEKDAY
2006            },
2007            false,
2008            false,
2009            false,
2010            false,
2011            false
2012        )
2013        .is_none());
2014    }
2015
2016    #[test]
2017    fn evaluate_guards_work_window_outside_fires_silently() {
2018        // Outside-hours suppression doesn't log — it's a scheduled
2019        // silence, not an unexpected event.
2020        let s = settings_for_guards(true, false, false, false, false);
2021        let outcome = evaluate_guards(
2022            &s,
2023            TickClock {
2024                now_min: OUTSIDE_WORK_WINDOW,
2025                weekday: ANY_WEEKDAY,
2026            },
2027            false,
2028            false,
2029            false,
2030            false,
2031            false,
2032        )
2033        .unwrap();
2034        assert_eq!(outcome.reason, SuppressReason::WorkWindow);
2035        assert!(
2036            outcome.log_as.is_none(),
2037            "work_window suppression must never log",
2038        );
2039    }
2040
2041    #[test]
2042    fn evaluate_guards_work_window_suppresses_on_disabled_weekday() {
2043        // Inside the time window, but today's weekday bit is cleared, so the
2044        // work-window guard still fires (silently) — the day toggle is the
2045        // new gate for #204.
2046        let mut s = settings_for_guards(true, false, false, false, false);
2047        s.work_days_mask = 0b001_1111; // Mon..Fri only
2048        let saturday = 5;
2049        let outcome = evaluate_guards(
2050            &s,
2051            TickClock {
2052                now_min: INSIDE_WORK_WINDOW,
2053                weekday: saturday,
2054            },
2055            false,
2056            false,
2057            false,
2058            false,
2059            false,
2060        )
2061        .unwrap();
2062        assert_eq!(outcome.reason, SuppressReason::WorkWindow);
2063        assert!(outcome.log_as.is_none());
2064    }
2065
2066    #[test]
2067    fn evaluate_guards_work_window_allows_enabled_weekday() {
2068        // Same window, but a weekday whose bit is set → happy path, no guard.
2069        let mut s = settings_for_guards(true, false, false, false, false);
2070        s.work_days_mask = 0b001_1111; // Mon..Fri only
2071        let wednesday = 2;
2072        assert!(evaluate_guards(
2073            &s,
2074            TickClock {
2075                now_min: INSIDE_WORK_WINDOW,
2076                weekday: wednesday
2077            },
2078            false,
2079            false,
2080            false,
2081            false,
2082            false,
2083        )
2084        .is_none());
2085    }
2086
2087    #[test]
2088    fn evaluate_guards_dnd_fires_only_when_setting_and_state_both_true() {
2089        let s_off = settings_for_guards(false, false, false, false, false);
2090        assert!(evaluate_guards(
2091            &s_off,
2092            TickClock {
2093                now_min: INSIDE_WORK_WINDOW,
2094                weekday: ANY_WEEKDAY
2095            },
2096            true,
2097            false,
2098            false,
2099            false,
2100            false
2101        )
2102        .is_none());
2103
2104        let s_on = settings_for_guards(false, true, false, false, false);
2105        let outcome = evaluate_guards(
2106            &s_on,
2107            TickClock {
2108                now_min: INSIDE_WORK_WINDOW,
2109                weekday: ANY_WEEKDAY,
2110            },
2111            true,
2112            false,
2113            false,
2114            false,
2115            false,
2116        )
2117        .unwrap();
2118        assert_eq!(outcome.reason, SuppressReason::Dnd);
2119        assert_eq!(outcome.log_as, Some(GuardReason::Dnd));
2120
2121        // Setting on but state false → no suppression.
2122        assert!(evaluate_guards(
2123            &s_on,
2124            TickClock {
2125                now_min: INSIDE_WORK_WINDOW,
2126                weekday: ANY_WEEKDAY
2127            },
2128            false,
2129            false,
2130            false,
2131            false,
2132            false
2133        )
2134        .is_none());
2135    }
2136
2137    #[test]
2138    fn evaluate_guards_camera_logs_camera_reason() {
2139        let s = settings_for_guards(false, false, true, false, false);
2140        let outcome = evaluate_guards(
2141            &s,
2142            TickClock {
2143                now_min: INSIDE_WORK_WINDOW,
2144                weekday: ANY_WEEKDAY,
2145            },
2146            false,
2147            true,
2148            false,
2149            false,
2150            false,
2151        )
2152        .unwrap();
2153        assert_eq!(outcome.reason, SuppressReason::Camera);
2154        assert_eq!(outcome.log_as, Some(GuardReason::Camera));
2155    }
2156
2157    #[test]
2158    fn evaluate_guards_video_logs_video_reason() {
2159        let s = settings_for_guards(false, false, false, true, false);
2160        let outcome = evaluate_guards(
2161            &s,
2162            TickClock {
2163                now_min: INSIDE_WORK_WINDOW,
2164                weekday: ANY_WEEKDAY,
2165            },
2166            false,
2167            false,
2168            true,
2169            false,
2170            false,
2171        )
2172        .unwrap();
2173        assert_eq!(outcome.reason, SuppressReason::Video);
2174        assert_eq!(outcome.log_as, Some(GuardReason::Video));
2175    }
2176
2177    #[test]
2178    fn evaluate_guards_app_pause_requires_nonempty_target_list() {
2179        // app_pause_enabled but the list is empty → not a valid match,
2180        // so the guard must not fire even when app_pause_active is true.
2181        let mut s = settings_for_guards(false, false, false, false, true);
2182        s.app_pause_list.clear();
2183        assert!(evaluate_guards(
2184            &s,
2185            TickClock {
2186                now_min: INSIDE_WORK_WINDOW,
2187                weekday: ANY_WEEKDAY
2188            },
2189            false,
2190            false,
2191            false,
2192            true,
2193            false
2194        )
2195        .is_none());
2196
2197        let with_target = settings_for_guards(false, false, false, false, true);
2198        let outcome = evaluate_guards(
2199            &with_target,
2200            TickClock {
2201                now_min: INSIDE_WORK_WINDOW,
2202                weekday: ANY_WEEKDAY,
2203            },
2204            false,
2205            false,
2206            false,
2207            true,
2208            false,
2209        )
2210        .unwrap();
2211        assert_eq!(outcome.reason, SuppressReason::AppPause);
2212        assert_eq!(outcome.log_as, Some(GuardReason::AppPause));
2213    }
2214
2215    #[test]
2216    fn evaluate_guards_plugin_suppress_fires_without_a_settings_gate() {
2217        // No setting toggles plugin suppression — a true verdict suppresses,
2218        // a false one doesn't.
2219        let s = settings_for_guards(false, false, false, false, false);
2220        assert!(evaluate_guards(
2221            &s,
2222            TickClock {
2223                now_min: INSIDE_WORK_WINDOW,
2224                weekday: ANY_WEEKDAY
2225            },
2226            false,
2227            false,
2228            false,
2229            false,
2230            false
2231        )
2232        .is_none());
2233        let outcome = evaluate_guards(
2234            &s,
2235            TickClock {
2236                now_min: INSIDE_WORK_WINDOW,
2237                weekday: ANY_WEEKDAY,
2238            },
2239            false,
2240            false,
2241            false,
2242            false,
2243            true,
2244        )
2245        .unwrap();
2246        assert_eq!(outcome.reason, SuppressReason::Plugin);
2247        assert_eq!(outcome.log_as, Some(GuardReason::Plugin));
2248    }
2249
2250    #[test]
2251    fn evaluate_guards_work_window_outranks_every_other_guard() {
2252        // First-match-wins precedence: even with every live signal
2253        // firing simultaneously, work_window short-circuits the rest
2254        // (and stays silent, per its no-log policy).
2255        let s = settings_for_guards(true, true, true, true, true);
2256        let outcome = evaluate_guards(
2257            &s,
2258            TickClock {
2259                now_min: OUTSIDE_WORK_WINDOW,
2260                weekday: ANY_WEEKDAY,
2261            },
2262            true,
2263            true,
2264            true,
2265            true,
2266            false,
2267        )
2268        .unwrap();
2269        assert_eq!(outcome.reason, SuppressReason::WorkWindow);
2270        assert!(outcome.log_as.is_none());
2271    }
2272
2273    #[test]
2274    fn evaluate_guards_dnd_outranks_camera_video_app_pause() {
2275        let s = settings_for_guards(true, true, true, true, true);
2276        // Inside the work window, so work_window does NOT fire.
2277        let outcome = evaluate_guards(
2278            &s,
2279            TickClock {
2280                now_min: INSIDE_WORK_WINDOW,
2281                weekday: ANY_WEEKDAY,
2282            },
2283            true,
2284            true,
2285            true,
2286            true,
2287            false,
2288        )
2289        .unwrap();
2290        assert_eq!(outcome.reason, SuppressReason::Dnd);
2291    }
2292
2293    #[test]
2294    fn evaluate_guards_camera_outranks_video_and_app_pause() {
2295        let s = settings_for_guards(true, true, true, true, true);
2296        let outcome = evaluate_guards(
2297            &s,
2298            TickClock {
2299                now_min: INSIDE_WORK_WINDOW,
2300                weekday: ANY_WEEKDAY,
2301            },
2302            false,
2303            true,
2304            true,
2305            true,
2306            false,
2307        )
2308        .unwrap();
2309        assert_eq!(outcome.reason, SuppressReason::Camera);
2310    }
2311
2312    #[test]
2313    fn evaluate_guards_video_outranks_app_pause() {
2314        let s = settings_for_guards(true, true, true, true, true);
2315        let outcome = evaluate_guards(
2316            &s,
2317            TickClock {
2318                now_min: INSIDE_WORK_WINDOW,
2319                weekday: ANY_WEEKDAY,
2320            },
2321            false,
2322            false,
2323            true,
2324            true,
2325            false,
2326        )
2327        .unwrap();
2328        assert_eq!(outcome.reason, SuppressReason::Video);
2329    }
2330
2331    #[test]
2332    fn evaluate_guards_app_pause_only_when_higher_guards_quiet() {
2333        let s = settings_for_guards(true, true, true, true, true);
2334        let outcome = evaluate_guards(
2335            &s,
2336            TickClock {
2337                now_min: INSIDE_WORK_WINDOW,
2338                weekday: ANY_WEEKDAY,
2339            },
2340            false,
2341            false,
2342            false,
2343            true,
2344            false,
2345        )
2346        .unwrap();
2347        assert_eq!(outcome.reason, SuppressReason::AppPause);
2348    }
2349
2350    /// Settings whose micro/long intervals are zero so `last.elapsed() >= 0`
2351    /// is always true with fresh timers — makes "overdue" hold regardless
2352    /// of the CI monotonic clock's age (no wall-time back-dating, which a
2353    /// young clock could turn into a no-op or panic).
2354    #[allow(clippy::field_reassign_with_default)]
2355    fn zero_interval_settings() -> Settings {
2356        let mut s = Settings::default();
2357        s.micro_interval_secs = 0;
2358        s.long_interval_secs = 0;
2359        s.rebuild_derived();
2360        s
2361    }
2362
2363    #[test]
2364    fn suppressed_kinds_reports_both_when_enabled_and_overdue() {
2365        let s = zero_interval_settings();
2366        assert_eq!(
2367            suppressed_kinds(&s, &super::super::timers::BreakTimers::new()),
2368            vec![BreakKind::Micro, BreakKind::Long]
2369        );
2370    }
2371
2372    #[test]
2373    fn suppressed_kinds_skips_disabled_kinds() {
2374        let mut s = zero_interval_settings();
2375        s.micro_enabled = false;
2376        assert_eq!(
2377            suppressed_kinds(&s, &super::super::timers::BreakTimers::new()),
2378            vec![BreakKind::Long]
2379        );
2380    }
2381
2382    #[test]
2383    fn suppressed_kinds_empty_when_not_yet_due() {
2384        // Default intervals are 1200s+ and fresh timers are anchored at now,
2385        // so neither kind has elapsed long enough to be due.
2386        let mut s = Settings::default();
2387        s.rebuild_derived();
2388        assert!(suppressed_kinds(&s, &super::super::timers::BreakTimers::new()).is_empty());
2389    }
2390
2391    #[test]
2392    fn log_suppressions_logs_each_suppressed_kind() {
2393        use crate::test_support::test_scheduler;
2394        let s = zero_interval_settings();
2395        let (_dir, sched) = test_scheduler(s.clone());
2396        // Exercises the thin logging wrapper: both kinds are due, so it
2397        // forwards a GuardSuppress event for each to the logger.
2398        log_suppressions(
2399            &sched.logger,
2400            &s,
2401            &super::super::timers::BreakTimers::new(),
2402            GuardReason::Idle,
2403        );
2404    }
2405}