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#[inline]
35fn import_pending(flag: &AtomicBool) -> bool {
36 flag.load(Ordering::Relaxed)
37}
38
39const SUSPEND_GAP_THRESHOLD: Duration = Duration::from_secs(30);
43
44#[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 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 let mut last_tick_wall = SystemTime::now();
72 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 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 sched.auto_suppress_reason.store(0, Ordering::Relaxed);
132
133 let s = sched.settings.lock().await.clone();
134 crate::media::set_enabled(s.pause_media_during_breaks);
138 let now_min = current_minutes();
139 let today_weekday = current_weekday();
140
141 let idle_reading = resolve_idle_secs(&mut idle_backoff, idle::idle_secs);
148 let raw_idle_secs = idle_reading.unwrap_or(0);
152 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 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 if resumed_from_suspend {
239 t.last_sleep = Some(Instant::now());
240 }
241 }
242 continue;
246 }
247 {
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 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 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 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 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
517pub(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
539async 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
583fn 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
614fn 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
639fn scheduled_break_event(
646 kind: BreakKind,
647 s: &Settings,
648 intensity: f32,
649 chore_prompt: Option<String>,
650) -> BreakEvent {
651 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
682const USER_IDLE_WARN_INTERVAL_SECS: i64 = 60;
686
687const IDLE_PROBE_BACKOFF_MAX_SECS: u64 = 300;
693
694fn 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
706struct IdleProbeBackoff {
713 cooldown_remaining: u64,
715 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 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 fn record_success(&mut self) {
742 self.consecutive_failures = 0;
743 self.cooldown_remaining = 0;
744 }
745
746 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
756static USER_IDLE_LAST_WARN_EPOCH: AtomicI64 = AtomicI64::new(0);
760
761fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
780pub(super) struct GuardOutcome {
781 pub reason: SuppressReason,
782 pub log_as: Option<GuardReason>,
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub(super) struct TickClock {
792 pub now_min: u32,
793 pub weekday: u32,
794}
795
796pub(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 if plugin_suppress {
857 return Some(GuardOutcome {
858 reason: SuppressReason::Plugin,
859 log_as: Some(GuardReason::Plugin),
860 });
861 }
862 None
863}
864
865fn 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
880static 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
913pub(super) enum LockTransition {
914 JustLocked,
915 JustUnlocked,
916}
917
918pub(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 _ => None,
938 }
939}
940
941pub(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
958fn 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
968pub(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 cell.store(encode_lock_state(locked), Ordering::Relaxed);
992 idle_secs_with_lock(raw_idle_secs, locked, s)
993}
994
995fn resolve_idle_secs(
1008 backoff: &mut IdleProbeBackoff,
1009 probe: impl FnOnce() -> Result<u64, String>,
1010) -> Option<u64> {
1011 if !backoff.should_probe() {
1012 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
1029pub(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
1049fn 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
1104fn 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
1132fn 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 #[cfg(not(target_os = "windows"))]
1153 use super::super::settings::BreakMode;
1154
1155 #[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 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 assert!(sched.current_break.lock().unwrap().is_none());
1188 }
1189
1190 #[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 #[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 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 assert_eq!(e.health_intensity, 0.5);
1275 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 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 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 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 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 #[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 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 fn settings_with_fixed(kind: BreakKind, mode: ScheduleMode, times: Vec<&str>) -> Settings {
1478 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 let s = settings_with_fixed(BreakKind::Long, ScheduleMode::Interval, vec!["09:00"]);
1514 assert!(!fixed_break_due(BreakKind::Long, &s, 540));
1515
1516 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 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 #[test]
1549 fn boot_anchor_never_panics_when_clock_is_young() {
1550 let anchor = Instant::now()
1554 .checked_sub(Duration::from_secs(60))
1555 .unwrap_or_else(Instant::now);
1556 let now = Instant::now();
1559 assert!(anchor <= now);
1560 }
1561
1562 #[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 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 assert!(!user_idle_warn_throttle(&cell, 1075, 60));
1588 }
1589
1590 #[test]
1591 fn user_idle_warn_throttle_handles_clock_jumping_backwards() {
1592 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 #[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 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 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 assert!(b.should_probe());
1642 assert_eq!(b.record_failure(), 2);
1643 assert!(!b.should_probe());
1645 assert!(!b.should_probe());
1646 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 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 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 assert_eq!(resolve_idle_secs(&mut b, || Err("boom".into())), None);
1675 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 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 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 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 assert!(!b.should_probe());
1709 assert!(!b.should_probe());
1710 assert!(b.should_probe());
1711 assert_eq!(b.record_failure(), 4);
1713 assert!(!b.should_probe());
1715 b.record_success();
1717 assert!(b.should_probe());
1718 assert_eq!(b.record_failure(), 2);
1720 }
1721
1722 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 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 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 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 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 let s = settings_with_idle_thresholds(60, 600);
1778 assert_eq!(idle_secs_with_lock(0, Some(true), &s), 600);
1779 let s = settings_with_idle_thresholds(600, 60);
1781 assert_eq!(idle_secs_with_lock(0, Some(true), &s), 600);
1782 }
1783
1784 #[test]
1787 fn lock_transition_first_reading_locked_logs_locked() {
1788 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 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 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 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 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 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 assert_eq!(decode_lock_state(0), None);
1866 assert_eq!(decode_lock_state(99), None);
1867 }
1868
1869 #[test]
1872 fn promote_with_cell_first_locked_reading_promotes_and_remembers() {
1873 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 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 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 let s = settings_with_idle_thresholds(120, 300);
1928 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 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 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 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 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 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 let mut s = settings_for_guards(true, false, false, false, false);
2047 s.work_days_mask = 0b001_1111; 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 let mut s = settings_for_guards(true, false, false, false, false);
2070 s.work_days_mask = 0b001_1111; 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 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 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 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 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 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 #[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 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 log_suppressions(
2399 &sched.logger,
2400 &s,
2401 &super::super::timers::BreakTimers::new(),
2402 GuardReason::Idle,
2403 );
2404 }
2405}