1use std::sync::atomic::{AtomicI64, Ordering};
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use sysinfo::{ProcessesToUpdate, System};
5use tauri::{AppHandle, Emitter};
6use tauri_plugin_notification::NotificationExt;
7use tokio::time::sleep;
8use user_idle::UserIdle;
9
10use crate::dnd;
11use crate::hooks::{self, HookContext, HookEvent};
12use crate::stats::{EventPayload, GuardReason, Logger};
13
14use super::overlay::deliver_break;
15use super::pause::{persist_pause, PauseState};
16use super::screen_time::{persist_screen_time, rollover_if_new_day, should_remind_screen_time};
17use super::settings::{delivery_for, effective_long_hints, effective_micro_hints, Settings};
18use super::timers::{
19 current_minutes, decide_bedtime, in_window, interval_break_due, local_today_string, parse_hhmm,
20 prebreak_warn_due, should_defer_for_typing, should_fire_fixed_now, BedtimeAction,
21};
22use super::types::{BreakDelivery, BreakKind, SuppressReason};
23use super::Scheduler;
24
25pub(super) async fn run_loop(app: AppHandle, sched: Scheduler) {
26 let mut sysinfo_system: Option<System> = None;
27 let mut last_app_refresh = Instant::now()
31 .checked_sub(Duration::from_secs(60))
32 .unwrap_or_else(Instant::now);
33 let mut app_pause_active = false;
34
35 loop {
36 sleep(Duration::from_secs(1)).await;
37
38 let now = Instant::now();
39 let mut just_resumed = false;
40 {
41 let mut state = sched.pause_state.lock().await;
42 if let PauseState::PausedUntil(Some(t)) = *state {
43 if now >= t {
44 *state = PauseState::Running;
45 just_resumed = true;
46 }
47 }
48 if !matches!(*state, PauseState::Running) {
49 continue;
50 }
51 }
52 if just_resumed {
53 persist_pause(&sched.pause_path, &PauseState::Running);
54 sched.logger.log(EventPayload::PauseEnd);
55 let _ = app.emit("pause:changed", false);
56 }
57
58 sched.auto_suppress_reason.store(0, Ordering::Relaxed);
62
63 let s = sched.settings.lock().await.clone();
64 let now_min = current_minutes();
65
66 let idle_secs = match UserIdle::get_time() {
70 Ok(i) => i.as_seconds(),
71 Err(e) => {
72 warn_user_idle_failure(&e);
73 0
78 }
79 };
80 let is_active = idle_secs < s.micro_idle_reset_secs;
81 let today_str = local_today_string();
82 let budget_secs = s.daily_screen_time_budget_minutes.saturating_mul(60);
83 let remind_again_secs = s.daily_screen_time_remind_again_minutes.saturating_mul(60);
84 let mut fire_screen_time_reminder = false;
85 {
86 let mut st = sched.screen_time.lock().await;
87 let rolled = rollover_if_new_day(&mut st, &today_str);
88 let mut changed = rolled;
89 if is_active {
90 st.seconds = st.seconds.saturating_add(1);
91 changed = true;
92 }
93 if should_remind_screen_time(
94 s.daily_screen_time_enabled,
95 st.seconds,
96 budget_secs,
97 st.last_reminder_epoch_secs,
98 remind_again_secs,
99 super::pause::now_epoch_secs(),
100 ) {
101 st.last_reminder_epoch_secs = Some(super::pause::now_epoch_secs());
102 fire_screen_time_reminder = true;
103 changed = true;
104 }
105 if changed {
106 persist_screen_time(&sched.screen_time_path, &st);
107 }
108 }
109 if fire_screen_time_reminder {
110 notify_screen_time_budget(&app, s.daily_screen_time_budget_minutes);
111 let _ = app.emit("screen_time:reminder", s.daily_screen_time_budget_minutes);
112 }
113
114 let bedtime_decision = {
119 let t = sched.timers.lock().await;
120 decide_bedtime(
121 s.bedtime_enabled,
122 now_min,
123 s.bedtime_start_minutes,
124 s.bedtime_end_minutes,
125 s.bedtime_interval_secs,
126 t.last_sleep,
127 now,
128 )
129 };
130 if !matches!(bedtime_decision, BedtimeAction::NotInWindow) {
131 if matches!(bedtime_decision, BedtimeAction::Fire) {
132 let intensity = sched.stats.lock().await.intensity();
133 super::overlay::fire_break(
134 &app,
135 &sched.current_break,
136 BreakKind::Sleep,
137 s.bedtime_duration_secs,
138 true,
139 s.monitor_placement,
140 super::settings::is_windowed_mode(BreakKind::Sleep, &s),
141 false,
142 false,
143 s.sleep_hints.clone(),
144 s.hint_rotate_seconds,
145 if s.break_health_enabled {
146 intensity
147 } else {
148 0.0
149 },
150 );
151 hooks::run_hooks(
152 &s,
153 HookEvent::BreakStart,
154 HookContext::with_kind_duration(BreakKind::Sleep, s.bedtime_duration_secs),
155 );
156 sched.logger.log(EventPayload::BreakStart {
157 kind: BreakKind::Sleep,
158 duration_secs: s.bedtime_duration_secs,
159 enforceable: true,
160 });
161 let mut t = sched.timers.lock().await;
162 t.last_sleep = Some(Instant::now());
163 t.last_micro = Instant::now();
164 t.last_long = Instant::now();
165 t.micro_deferred_since = None;
166 t.long_deferred_since = None;
167 t.active_break = Some(BreakKind::Sleep);
168 } else {
169 let mut t = sched.timers.lock().await;
170 t.last_micro = Instant::now();
171 t.last_long = Instant::now();
172 t.micro_deferred_since = None;
173 t.long_deferred_since = None;
174 }
175 continue;
179 }
180 sched.timers.lock().await.last_sleep = None;
181
182 let dnd_live = s.pause_during_dnd && dnd::is_active();
186 let camera_live = s.pause_during_camera && sched.camera_active.load(Ordering::Relaxed);
187 let video_live = s.pause_during_video && sched.video_active.load(Ordering::Relaxed);
188 if s.app_pause_enabled && !s.app_pause_list.is_empty() {
189 if last_app_refresh.elapsed() >= Duration::from_secs(5) {
190 let sys = sysinfo_system.get_or_insert_with(System::new);
191 sys.refresh_processes(ProcessesToUpdate::All, false);
192 app_pause_active = sys.processes().values().any(|p| {
193 let proc_name = p.name().to_string_lossy().to_string();
194 s.app_pause_list
195 .iter()
196 .any(|target| process_match(&proc_name, target))
197 });
198 last_app_refresh = Instant::now();
199 }
200 } else {
201 sysinfo_system = None;
202 app_pause_active = false;
203 }
204
205 if let Some(outcome) = evaluate_guards(
206 &s,
207 now_min,
208 dnd_live,
209 camera_live,
210 video_live,
211 app_pause_active,
212 ) {
213 let mut t = sched.timers.lock().await;
214 if let Some(guard_reason) = outcome.log_as {
215 log_suppressions(&sched.logger, &s, &t, guard_reason);
216 }
217 t.last_micro = Instant::now();
218 t.last_long = Instant::now();
219 t.micro_deferred_since = None;
220 t.long_deferred_since = None;
221 sched
222 .auto_suppress_reason
223 .store(outcome.reason.as_u8(), Ordering::Relaxed);
224 continue;
225 }
226
227 let long_fixed_due = s.long_enabled
228 && matches!(s.long_schedule_mode.as_str(), "fixed" | "both")
229 && s.long_fixed_times
230 .iter()
231 .filter_map(|t| parse_hhmm(t))
232 .any(|m| m == now_min);
233 let micro_fixed_due = s.micro_enabled
234 && matches!(s.micro_schedule_mode.as_str(), "fixed" | "both")
235 && s.micro_fixed_times
236 .iter()
237 .filter_map(|t| parse_hhmm(t))
238 .any(|m| m == now_min);
239
240 if long_fixed_due || micro_fixed_due {
241 let (fire_long, fire_micro) = {
242 let t = sched.timers.lock().await;
243 (
244 long_fixed_due
245 && should_fire_fixed_now(
246 &today_str,
247 now_min,
248 t.last_long_fixed_fire.as_ref(),
249 ),
250 micro_fixed_due
251 && should_fire_fixed_now(
252 &today_str,
253 now_min,
254 t.last_micro_fixed_fire.as_ref(),
255 ),
256 )
257 };
258 if fire_long {
260 let enforceable = s.long_enforceable || s.strict_mode;
261 let intensity = sched.stats.lock().await.intensity();
262 let delivery = delivery_for(BreakKind::Long, &s);
263 deliver_break(
264 &app,
265 &sched.current_break,
266 delivery,
267 BreakKind::Long,
268 s.long_duration_secs,
269 enforceable,
270 s.monitor_placement,
271 s.long_manual_finish,
272 s.postpone_enabled && !s.strict_mode,
273 effective_long_hints(&s),
274 s.hint_rotate_seconds,
275 if s.break_health_enabled {
276 intensity
277 } else {
278 0.0
279 },
280 );
281 sched.logger.log(EventPayload::BreakStart {
282 kind: BreakKind::Long,
283 duration_secs: s.long_duration_secs,
284 enforceable,
285 });
286 let mut t = sched.timers.lock().await;
287 t.last_long = Instant::now();
288 t.last_micro = Instant::now();
289 t.long_warned = false;
290 t.micro_warned = false;
291 t.long_deferred_since = None;
292 t.micro_deferred_since = None;
293 if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
294 t.active_break = Some(BreakKind::Long);
295 }
296 t.last_long_fixed_fire = Some((today_str.clone(), now_min));
297 continue;
298 }
299 if fire_micro {
300 let enforceable = s.micro_enforceable || s.strict_mode;
301 let intensity = sched.stats.lock().await.intensity();
302 let delivery = delivery_for(BreakKind::Micro, &s);
303 deliver_break(
304 &app,
305 &sched.current_break,
306 delivery,
307 BreakKind::Micro,
308 s.micro_duration_secs,
309 enforceable,
310 s.monitor_placement,
311 s.micro_manual_finish,
312 s.postpone_enabled && !s.strict_mode,
313 effective_micro_hints(&s),
314 s.hint_rotate_seconds,
315 if s.break_health_enabled {
316 intensity
317 } else {
318 0.0
319 },
320 );
321 sched.logger.log(EventPayload::BreakStart {
322 kind: BreakKind::Micro,
323 duration_secs: s.micro_duration_secs,
324 enforceable,
325 });
326 let mut t = sched.timers.lock().await;
327 t.last_micro = Instant::now();
328 t.micro_warned = false;
329 t.micro_deferred_since = None;
330 if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
331 t.active_break = Some(BreakKind::Micro);
332 }
333 t.last_micro_fixed_fire = Some((today_str.clone(), now_min));
334 continue;
335 }
336 }
337
338 let micro_interval_active = matches!(s.micro_schedule_mode.as_str(), "interval" | "both");
339 let long_interval_active = matches!(s.long_schedule_mode.as_str(), "interval" | "both");
340
341 let (micro_idle_suppressed, long_idle_suppressed) = (
342 idle_secs >= s.micro_idle_reset_secs,
343 idle_secs >= s.long_idle_reset_secs,
344 );
345
346 if micro_idle_suppressed || long_idle_suppressed {
347 let mut t = sched.timers.lock().await;
348 log_suppressions(&sched.logger, &s, &t, GuardReason::Idle);
349 if micro_idle_suppressed {
350 t.last_micro = Instant::now();
351 t.micro_deferred_since = None;
352 }
353 if long_idle_suppressed {
354 t.last_long = Instant::now();
355 t.long_deferred_since = None;
356 }
357 if micro_idle_suppressed && long_idle_suppressed {
358 continue;
359 }
360 }
361
362 let tick_now = Instant::now();
363
364 if s.prebreak_notification_enabled && s.prebreak_notification_seconds > 0 {
365 let mut t = sched.timers.lock().await;
366 if prebreak_warn_due(
367 s.long_enabled,
368 long_interval_active,
369 t.last_long,
370 s.long_interval_secs,
371 s.prebreak_notification_seconds,
372 t.long_warned,
373 long_idle_suppressed,
374 tick_now,
375 ) {
376 notify_break_coming(&app, BreakKind::Long, s.prebreak_notification_seconds);
377 t.long_warned = true;
378 }
379 if prebreak_warn_due(
380 s.micro_enabled,
381 micro_interval_active,
382 t.last_micro,
383 s.micro_interval_secs,
384 s.prebreak_notification_seconds,
385 t.micro_warned,
386 micro_idle_suppressed,
387 tick_now,
388 ) {
389 notify_break_coming(&app, BreakKind::Micro, s.prebreak_notification_seconds);
390 t.micro_warned = true;
391 }
392 }
393
394 let (should_fire_long, should_fire_micro) = {
395 let t = sched.timers.lock().await;
396 (
397 interval_break_due(
398 s.long_enabled,
399 long_interval_active,
400 t.last_long,
401 s.long_interval_secs,
402 long_idle_suppressed,
403 tick_now,
404 ),
405 interval_break_due(
406 s.micro_enabled,
407 micro_interval_active,
408 t.last_micro,
409 s.micro_interval_secs,
410 micro_idle_suppressed,
411 tick_now,
412 ),
413 )
414 };
415
416 if should_fire_long || should_fire_micro {
417 let mut t = sched.timers.lock().await;
418 let kind = if should_fire_long {
419 BreakKind::Long
420 } else {
421 BreakKind::Micro
422 };
423 let deferred_since = match kind {
424 BreakKind::Long => t.long_deferred_since,
425 BreakKind::Micro => t.micro_deferred_since,
426 BreakKind::Sleep => None,
427 };
428 let defer = should_defer_for_typing(
429 s.delay_break_if_typing,
430 idle_secs,
431 s.typing_grace_secs,
432 deferred_since,
433 s.typing_max_deferral_secs,
434 tick_now,
435 );
436 if defer {
437 let newly_deferred = deferred_since.is_none();
438 match kind {
439 BreakKind::Long => {
440 if newly_deferred {
441 t.long_deferred_since = Some(tick_now);
442 sched.logger.log(EventPayload::GuardSuppress {
443 kind: BreakKind::Long,
444 reason: GuardReason::Typing,
445 });
446 }
447 }
448 BreakKind::Micro => {
449 if newly_deferred {
450 t.micro_deferred_since = Some(tick_now);
451 sched.logger.log(EventPayload::GuardSuppress {
452 kind: BreakKind::Micro,
453 reason: GuardReason::Typing,
454 });
455 }
456 }
457 BreakKind::Sleep => {}
458 }
459 continue;
460 }
461 }
462
463 if should_fire_long {
464 let enforceable = s.long_enforceable || s.strict_mode;
465 let intensity = sched.stats.lock().await.intensity();
466 let delivery = delivery_for(BreakKind::Long, &s);
467 deliver_break(
468 &app,
469 &sched.current_break,
470 delivery,
471 BreakKind::Long,
472 s.long_duration_secs,
473 enforceable,
474 s.monitor_placement,
475 s.long_manual_finish,
476 s.postpone_enabled && !s.strict_mode,
477 effective_long_hints(&s),
478 s.hint_rotate_seconds,
479 if s.break_health_enabled {
480 intensity
481 } else {
482 0.0
483 },
484 );
485 hooks::run_hooks(
486 &s,
487 HookEvent::BreakStart,
488 HookContext::with_kind_duration(BreakKind::Long, s.long_duration_secs),
489 );
490 sched.logger.log(EventPayload::BreakStart {
491 kind: BreakKind::Long,
492 duration_secs: s.long_duration_secs,
493 enforceable,
494 });
495 let mut t = sched.timers.lock().await;
496 t.last_long = Instant::now();
497 t.last_micro = Instant::now();
498 t.long_warned = false;
499 t.micro_warned = false;
500 t.long_deferred_since = None;
501 t.micro_deferred_since = None;
502 if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
503 t.active_break = Some(BreakKind::Long);
504 }
505 } else if should_fire_micro {
506 let enforceable = s.micro_enforceable || s.strict_mode;
507 let intensity = sched.stats.lock().await.intensity();
508 let delivery = delivery_for(BreakKind::Micro, &s);
509 deliver_break(
510 &app,
511 &sched.current_break,
512 delivery,
513 BreakKind::Micro,
514 s.micro_duration_secs,
515 enforceable,
516 s.monitor_placement,
517 s.micro_manual_finish,
518 s.postpone_enabled && !s.strict_mode,
519 effective_micro_hints(&s),
520 s.hint_rotate_seconds,
521 if s.break_health_enabled {
522 intensity
523 } else {
524 0.0
525 },
526 );
527 hooks::run_hooks(
528 &s,
529 HookEvent::BreakStart,
530 HookContext::with_kind_duration(BreakKind::Micro, s.micro_duration_secs),
531 );
532 sched.logger.log(EventPayload::BreakStart {
533 kind: BreakKind::Micro,
534 duration_secs: s.micro_duration_secs,
535 enforceable,
536 });
537 let mut t = sched.timers.lock().await;
538 t.last_micro = Instant::now();
539 t.micro_warned = false;
540 t.micro_deferred_since = None;
541 if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
542 t.active_break = Some(BreakKind::Micro);
543 }
544 }
545 }
546}
547
548const USER_IDLE_WARN_INTERVAL_SECS: i64 = 60;
552
553static USER_IDLE_LAST_WARN_EPOCH: AtomicI64 = AtomicI64::new(0);
557
558fn now_epoch_secs_for_warn() -> i64 {
562 SystemTime::now()
563 .duration_since(UNIX_EPOCH)
564 .map(|d| d.as_secs() as i64)
565 .unwrap_or(0)
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
577pub(super) struct GuardOutcome {
578 pub reason: SuppressReason,
579 pub log_as: Option<GuardReason>,
580}
581
582pub(super) fn evaluate_guards(
593 s: &Settings,
594 now_min: u32,
595 dnd_active: bool,
596 camera_active: bool,
597 video_active: bool,
598 app_pause_active: bool,
599) -> Option<GuardOutcome> {
600 if s.work_window_enabled && !in_window(now_min, s.work_start_minutes, s.work_end_minutes) {
601 return Some(GuardOutcome {
602 reason: SuppressReason::WorkWindow,
603 log_as: None,
604 });
605 }
606 if s.pause_during_dnd && dnd_active {
607 return Some(GuardOutcome {
608 reason: SuppressReason::Dnd,
609 log_as: Some(GuardReason::Dnd),
610 });
611 }
612 if s.pause_during_camera && camera_active {
613 return Some(GuardOutcome {
614 reason: SuppressReason::Camera,
615 log_as: Some(GuardReason::Camera),
616 });
617 }
618 if s.pause_during_video && video_active {
619 return Some(GuardOutcome {
620 reason: SuppressReason::Video,
621 log_as: Some(GuardReason::Video),
622 });
623 }
624 if s.app_pause_enabled && !s.app_pause_list.is_empty() && app_pause_active {
625 return Some(GuardOutcome {
626 reason: SuppressReason::AppPause,
627 log_as: Some(GuardReason::AppPause),
628 });
629 }
630 None
631}
632
633fn user_idle_warn_throttle(cell: &AtomicI64, now_epoch: i64, min_interval_secs: i64) -> bool {
640 let prev = cell.load(Ordering::Relaxed);
641 if prev != 0 && now_epoch.saturating_sub(prev) < min_interval_secs {
642 return false;
643 }
644 cell.store(now_epoch, Ordering::Relaxed);
645 true
646}
647
648fn warn_user_idle_failure(err: &user_idle::Error) {
654 if user_idle_warn_throttle(
655 &USER_IDLE_LAST_WARN_EPOCH,
656 now_epoch_secs_for_warn(),
657 USER_IDLE_WARN_INTERVAL_SECS,
658 ) {
659 log::warn!("scheduler: UserIdle::get_time failed (treating user as active): {err}");
660 }
661}
662
663fn notify_break_coming(app: &AppHandle, kind: BreakKind, seconds: u64) {
664 let title = match kind {
665 BreakKind::Micro => "Micro break coming up",
666 BreakKind::Long => "Long break coming up",
667 BreakKind::Sleep => "Bedtime reminder coming up",
668 };
669 let body = format!("Starting in {}s", seconds);
670 let _ = app.notification().builder().title(title).body(body).show();
671}
672
673fn notify_screen_time_budget(app: &AppHandle, budget_minutes: u64) {
674 let hours = budget_minutes / 60;
675 let mins = budget_minutes % 60;
676 let body = if hours > 0 && mins == 0 {
677 format!(
678 "You've been at the screen {} hour{} — time to wrap up.",
679 hours,
680 if hours == 1 { "" } else { "s" }
681 )
682 } else if hours == 0 {
683 format!("You've been at the screen {mins} minutes — time to wrap up.")
684 } else {
685 format!("You've been at the screen {hours}h {mins}m — time to wrap up.")
686 };
687 let _ = app
688 .notification()
689 .builder()
690 .title("Time to wind down")
691 .body(body)
692 .show();
693}
694
695fn log_suppressions(
696 logger: &Logger,
697 s: &Settings,
698 t: &super::timers::BreakTimers,
699 reason: GuardReason,
700) {
701 if s.micro_enabled && t.last_micro.elapsed() >= Duration::from_secs(s.micro_interval_secs) {
702 logger.log(EventPayload::GuardSuppress {
703 kind: BreakKind::Micro,
704 reason,
705 });
706 }
707 if s.long_enabled && t.last_long.elapsed() >= Duration::from_secs(s.long_interval_secs) {
708 logger.log(EventPayload::GuardSuppress {
709 kind: BreakKind::Long,
710 reason,
711 });
712 }
713}
714
715fn process_match(running: &str, target: &str) -> bool {
725 let r = running.to_lowercase();
726 let t = target.to_lowercase();
727 if t.is_empty() {
728 return false;
729 }
730 let target_is_single_token = t.chars().all(|c| c.is_alphanumeric());
731 if !target_is_single_token {
732 return r.contains(&t);
733 }
734 r.split(|c: char| !c.is_alphanumeric()).any(|tok| {
735 if tok == t {
736 return true;
737 }
738 if let Some(suffix) = tok.strip_prefix(t.as_str()) {
739 !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
740 } else {
741 false
742 }
743 })
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[test]
751 fn process_match_matches_whole_token() {
752 assert!(process_match("zoom.us", "zoom"));
754 assert!(process_match("OBS Studio", "obs"));
755 assert!(process_match("zoom", "zoom"));
756 assert!(process_match(
757 "/Applications/zoom.us.app/Contents/MacOS/zoom.us",
758 "zoom"
759 ));
760 assert!(process_match("Zoom Meeting Helper", "zoom"));
761 }
762
763 #[test]
764 fn process_match_rejects_substring_collisions() {
765 assert!(!process_match("zoominfo.exe", "zoom"));
768 assert!(!process_match("azoomatic", "zoom"));
769 assert!(!process_match("doomsday", "doom"));
770 }
771
772 #[test]
773 fn process_match_allows_digit_versioned_binaries() {
774 assert!(process_match("obs64.exe", "obs"));
778 assert!(process_match("OBS32.exe", "obs"));
779 assert!(process_match("firefox64.exe", "firefox"));
780 assert!(!process_match("firefoxnightly.exe", "firefox"));
782 }
783
784 #[test]
785 fn process_match_rejects_unrelated_apps() {
786 assert!(!process_match("safari", "zoom"));
787 assert!(!process_match("", "zoom"));
788 }
789
790 #[test]
791 fn process_match_falls_back_to_substring_for_multi_token_targets() {
792 assert!(process_match("/usr/bin/osascript -e foo", "osascript -e"));
796 assert!(!process_match("osascript", "osascript -e"));
797 }
798
799 #[test]
800 fn process_match_empty_target_never_matches() {
801 assert!(!process_match("zoom.us", ""));
804 assert!(!process_match("", ""));
805 }
806
807 #[test]
811 fn boot_anchor_never_panics_when_clock_is_young() {
812 let anchor = Instant::now()
816 .checked_sub(Duration::from_secs(60))
817 .unwrap_or_else(Instant::now);
818 let now = Instant::now();
821 assert!(anchor <= now);
822 }
823
824 #[test]
828 fn user_idle_warn_throttle_fires_first_warning() {
829 let cell = AtomicI64::new(0);
830 assert!(user_idle_warn_throttle(&cell, 1000, 60));
831 assert_eq!(cell.load(Ordering::Relaxed), 1000);
832 }
833
834 #[test]
835 fn user_idle_warn_throttle_suppresses_within_window() {
836 let cell = AtomicI64::new(1000);
837 assert!(!user_idle_warn_throttle(&cell, 1030, 60));
838 assert!(!user_idle_warn_throttle(&cell, 1059, 60));
839 assert_eq!(cell.load(Ordering::Relaxed), 1000);
841 }
842
843 #[test]
844 fn user_idle_warn_throttle_refires_after_window() {
845 let cell = AtomicI64::new(1000);
846 assert!(user_idle_warn_throttle(&cell, 1060, 60));
847 assert_eq!(cell.load(Ordering::Relaxed), 1060);
848 assert!(!user_idle_warn_throttle(&cell, 1075, 60));
850 }
851
852 #[test]
853 fn user_idle_warn_throttle_handles_clock_jumping_backwards() {
854 let cell = AtomicI64::new(2000);
858 assert!(!user_idle_warn_throttle(&cell, 1500, 60));
859 assert_eq!(cell.load(Ordering::Relaxed), 2000);
860 }
861
862 fn settings_for_guards(
865 work_window: bool,
866 dnd: bool,
867 camera: bool,
868 video: bool,
869 app_pause_with_targets: bool,
870 ) -> Settings {
871 Settings {
872 work_window_enabled: work_window,
873 work_start_minutes: 9 * 60,
874 work_end_minutes: 17 * 60,
875 pause_during_dnd: dnd,
876 pause_during_camera: camera,
877 pause_during_video: video,
878 app_pause_enabled: app_pause_with_targets,
879 app_pause_list: if app_pause_with_targets {
880 vec!["zoom".to_string()]
881 } else {
882 Vec::new()
883 },
884 ..Settings::default()
885 }
886 }
887
888 const INSIDE_WORK_WINDOW: u32 = 10 * 60;
889 const OUTSIDE_WORK_WINDOW: u32 = 20 * 60;
890
891 #[test]
892 fn evaluate_guards_returns_none_when_all_off() {
893 let s = settings_for_guards(false, false, false, false, false);
894 assert!(evaluate_guards(&s, INSIDE_WORK_WINDOW, true, true, true, true).is_none());
895 }
896
897 #[test]
898 fn evaluate_guards_work_window_inside_returns_none() {
899 let s = settings_for_guards(true, false, false, false, false);
902 assert!(evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, false, false).is_none());
903 }
904
905 #[test]
906 fn evaluate_guards_work_window_outside_fires_silently() {
907 let s = settings_for_guards(true, false, false, false, false);
910 let outcome = evaluate_guards(&s, OUTSIDE_WORK_WINDOW, false, false, false, false).unwrap();
911 assert_eq!(outcome.reason, SuppressReason::WorkWindow);
912 assert!(
913 outcome.log_as.is_none(),
914 "work_window suppression must never log",
915 );
916 }
917
918 #[test]
919 fn evaluate_guards_dnd_fires_only_when_setting_and_state_both_true() {
920 let s_off = settings_for_guards(false, false, false, false, false);
921 assert!(evaluate_guards(&s_off, INSIDE_WORK_WINDOW, true, false, false, false).is_none());
922
923 let s_on = settings_for_guards(false, true, false, false, false);
924 let outcome =
925 evaluate_guards(&s_on, INSIDE_WORK_WINDOW, true, false, false, false).unwrap();
926 assert_eq!(outcome.reason, SuppressReason::Dnd);
927 assert_eq!(outcome.log_as, Some(GuardReason::Dnd));
928
929 assert!(evaluate_guards(&s_on, INSIDE_WORK_WINDOW, false, false, false, false).is_none());
931 }
932
933 #[test]
934 fn evaluate_guards_camera_logs_camera_reason() {
935 let s = settings_for_guards(false, false, true, false, false);
936 let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, true, false, false).unwrap();
937 assert_eq!(outcome.reason, SuppressReason::Camera);
938 assert_eq!(outcome.log_as, Some(GuardReason::Camera));
939 }
940
941 #[test]
942 fn evaluate_guards_video_logs_video_reason() {
943 let s = settings_for_guards(false, false, false, true, false);
944 let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, true, false).unwrap();
945 assert_eq!(outcome.reason, SuppressReason::Video);
946 assert_eq!(outcome.log_as, Some(GuardReason::Video));
947 }
948
949 #[test]
950 fn evaluate_guards_app_pause_requires_nonempty_target_list() {
951 let mut s = settings_for_guards(false, false, false, false, true);
954 s.app_pause_list.clear();
955 assert!(evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, false, true).is_none());
956
957 let with_target = settings_for_guards(false, false, false, false, true);
958 let outcome =
959 evaluate_guards(&with_target, INSIDE_WORK_WINDOW, false, false, false, true).unwrap();
960 assert_eq!(outcome.reason, SuppressReason::AppPause);
961 assert_eq!(outcome.log_as, Some(GuardReason::AppPause));
962 }
963
964 #[test]
965 fn evaluate_guards_work_window_outranks_every_other_guard() {
966 let s = settings_for_guards(true, true, true, true, true);
970 let outcome = evaluate_guards(&s, OUTSIDE_WORK_WINDOW, true, true, true, true).unwrap();
971 assert_eq!(outcome.reason, SuppressReason::WorkWindow);
972 assert!(outcome.log_as.is_none());
973 }
974
975 #[test]
976 fn evaluate_guards_dnd_outranks_camera_video_app_pause() {
977 let s = settings_for_guards(true, true, true, true, true);
978 let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, true, true, true, true).unwrap();
980 assert_eq!(outcome.reason, SuppressReason::Dnd);
981 }
982
983 #[test]
984 fn evaluate_guards_camera_outranks_video_and_app_pause() {
985 let s = settings_for_guards(true, true, true, true, true);
986 let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, true, true, true).unwrap();
987 assert_eq!(outcome.reason, SuppressReason::Camera);
988 }
989
990 #[test]
991 fn evaluate_guards_video_outranks_app_pause() {
992 let s = settings_for_guards(true, true, true, true, true);
993 let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, true, true).unwrap();
994 assert_eq!(outcome.reason, SuppressReason::Video);
995 }
996
997 #[test]
998 fn evaluate_guards_app_pause_only_when_higher_guards_quiet() {
999 let s = settings_for_guards(true, true, true, true, true);
1000 let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, false, true).unwrap();
1001 assert_eq!(outcome.reason, SuppressReason::AppPause);
1002 }
1003}