1use std::time::{Duration, Instant};
2
3use tauri::{AppHandle, Emitter, Manager};
4
5use crate::hooks::{self, HookContext, HookEvent};
6use crate::stats::{EventPayload, Outcome, SkipSource};
7
8use super::super::overlay::{deliver_break, fire_break};
9use super::super::pause::{persist_pause, PauseInfo, PauseState};
10use super::super::settings::{
11 delivery_for, effective_long_hints, effective_micro_hints, is_windowed_mode, Settings,
12};
13use super::super::timers::{clear_last_break, postpone_counter, reset_postpone_counter};
14use super::super::types::{BreakKind, LastBreakInfo, PostponeState};
15use super::super::Scheduler;
16
17#[tauri::command]
22pub async fn pause(
23 app: AppHandle,
24 scheduler: tauri::State<'_, Scheduler>,
25 duration_secs: Option<u64>,
26) -> Result<(), String> {
27 pause_impl(scheduler.inner(), duration_secs).await;
28 let _ = app.emit("pause:changed", true);
29 Ok(())
30}
31
32#[tauri::command]
35pub async fn resume(app: AppHandle, scheduler: tauri::State<'_, Scheduler>) -> Result<(), String> {
36 resume_impl(scheduler.inner()).await;
37 let _ = app.emit("pause:changed", false);
38 Ok(())
39}
40
41pub async fn pause_impl(scheduler: &Scheduler, duration_secs: Option<u64>) {
45 let until = duration_secs.map(|s| Instant::now() + Duration::from_secs(s));
46 let new_state = PauseState::PausedUntil(until);
47 let was_running;
48 {
49 let mut guard = scheduler.pause_state.lock().await;
50 was_running = matches!(*guard, PauseState::Running);
51 *guard = new_state.clone();
52 }
53 persist_pause(&scheduler.pause_path, &new_state);
54 if was_running {
55 scheduler
56 .logger
57 .log(EventPayload::PauseStart { duration_secs });
58 let settings_snapshot = scheduler.settings.lock().await.clone();
59 hooks::run_hooks(
60 &settings_snapshot,
61 HookEvent::PauseStart,
62 HookContext::empty(),
63 );
64 }
65}
66
67pub async fn resume_impl(scheduler: &Scheduler) {
70 let was_paused;
71 {
72 let mut guard = scheduler.pause_state.lock().await;
73 was_paused = !matches!(*guard, PauseState::Running);
74 *guard = PauseState::Running;
75 }
76 persist_pause(&scheduler.pause_path, &PauseState::Running);
77 if was_paused {
78 scheduler.logger.log(EventPayload::PauseEnd);
79 let settings_snapshot = scheduler.settings.lock().await.clone();
80 hooks::run_hooks(
81 &settings_snapshot,
82 HookEvent::PauseEnd,
83 HookContext::empty(),
84 );
85 }
86}
87
88#[tauri::command]
92pub async fn get_pause_info(scheduler: tauri::State<'_, Scheduler>) -> Result<PauseInfo, String> {
93 let state = scheduler.pause_state.lock().await;
94 Ok(match &*state {
95 PauseState::Running => PauseInfo {
96 paused: false,
97 remaining_secs: None,
98 },
99 PauseState::PausedUntil(None) => PauseInfo {
100 paused: true,
101 remaining_secs: None,
102 },
103 PauseState::PausedUntil(Some(t)) => {
104 let now = Instant::now();
105 let remaining = if *t > now { (*t - now).as_secs() } else { 0 };
106 PauseInfo {
107 paused: true,
108 remaining_secs: Some(remaining),
109 }
110 }
111 })
112}
113
114pub(crate) fn test_break_enforceable(kind: BreakKind, s: &Settings) -> bool {
119 match kind {
120 BreakKind::Micro => s.micro_enforceable || s.strict_mode,
121 BreakKind::Long => s.long_enforceable || s.strict_mode,
122 BreakKind::Sleep => true,
123 }
124}
125
126pub async fn trigger_break_from_cli(
130 app: &AppHandle,
131 scheduler: &Scheduler,
132 kind: BreakKind,
133 duration_secs: u64,
134) {
135 let s = scheduler.settings.lock().await.clone();
136 let hints = match kind {
137 BreakKind::Micro => effective_micro_hints(&s),
138 BreakKind::Long => effective_long_hints(&s),
139 BreakKind::Sleep => s.sleep_hints.clone(),
140 };
141 let manual_finish = match kind {
142 BreakKind::Micro => s.micro_manual_finish,
143 BreakKind::Long => s.long_manual_finish,
144 BreakKind::Sleep => false,
145 };
146 let intensity = scheduler.stats.lock().await.intensity();
147 let delivery = delivery_for(kind, &s);
148 let enforceable = test_break_enforceable(kind, &s);
149 deliver_break(
150 app,
151 &scheduler.current_break,
152 delivery,
153 kind,
154 duration_secs,
155 enforceable,
156 s.monitor_placement,
157 manual_finish,
158 s.postpone_enabled && !s.strict_mode,
159 hints,
160 s.hint_rotate_seconds,
161 if s.break_health_enabled {
162 intensity
163 } else {
164 0.0
165 },
166 );
167 hooks::run_hooks(
168 &s,
169 HookEvent::BreakStart,
170 HookContext::with_kind_duration(kind, duration_secs),
171 );
172}
173
174#[tauri::command]
177pub async fn trigger_test_break(
178 app: AppHandle,
179 scheduler: tauri::State<'_, Scheduler>,
180 kind: BreakKind,
181 duration_secs: u64,
182) -> Result<(), String> {
183 trigger_break_from_cli(&app, &scheduler, kind, duration_secs).await;
184 Ok(())
185}
186
187#[tauri::command]
193pub async fn end_break(
194 app: AppHandle,
195 scheduler: tauri::State<'_, Scheduler>,
196 reason: Option<String>,
197) -> Result<(), String> {
198 let reason = reason.unwrap_or_else(|| "completed".to_string());
199 {
200 let mut stats = scheduler.stats.lock().await;
201 match reason.as_str() {
202 "completed" => stats.taken = stats.taken.saturating_add(1),
203 "dismissed" => stats.skipped = stats.skipped.saturating_add(1),
204 "postponed" => stats.postponed = stats.postponed.saturating_add(1),
205 _ => {}
206 }
207 }
208
209 let active_kind = {
210 let mut t = scheduler.timers.lock().await;
211 t.active_break.take()
212 };
213 if let Some(kind) = active_kind {
214 if reason == "completed" {
215 let mut t = scheduler.timers.lock().await;
216 reset_postpone_counter(&mut t, kind);
217 let cleared = clear_last_break(&mut t);
218 drop(t);
219 if cleared {
220 let _ = app.emit("last_break:changed", LastBreakInfo { kind: None });
221 }
222 }
223 let outcome = match reason.as_str() {
224 "dismissed" => Some(Outcome::Dismissed),
225 "completed" => Some(Outcome::Completed),
226 _ => None,
227 };
228 if let Some(o) = outcome {
229 scheduler
230 .logger
231 .log(EventPayload::BreakEnd { kind, outcome: o });
232 }
233 if matches!(reason.as_str(), "completed" | "dismissed") {
234 let settings_snapshot = scheduler.settings.lock().await.clone();
235 hooks::run_hooks(
236 &settings_snapshot,
237 HookEvent::BreakEnd,
238 HookContext::with_kind_outcome(kind, reason.clone()),
239 );
240 }
241 }
242
243 if let Ok(mut slot) = scheduler.current_break.lock() {
244 *slot = None;
245 }
246 for (label, window) in app.webview_windows() {
247 if label.starts_with("overlay-") {
248 let _ = window.hide();
249 }
250 }
251 let _ = app.emit("break:end", ());
252 let stats = scheduler.stats.lock().await.clone();
253 let _ = app.emit("stats:changed", &stats);
254 Ok(())
255}
256
257#[tauri::command]
264pub async fn postpone_break(
265 app: AppHandle,
266 scheduler: tauri::State<'_, Scheduler>,
267 kind: BreakKind,
268) -> Result<(), String> {
269 postpone_break_impl(scheduler.inner(), kind).await?;
270 for (label, window) in app.webview_windows() {
271 if label.starts_with("overlay-") {
272 let _ = window.hide();
273 }
274 }
275 let _ = app.emit("break:end", ());
276 let _ = app.emit("last_break:changed", LastBreakInfo { kind: Some(kind) });
277 Ok(())
278}
279
280#[derive(Debug, Clone, Copy)]
283pub struct PostponeOutcome {
284 #[allow(dead_code)]
285 pub postpone_secs: u64,
286}
287
288pub async fn postpone_break_impl(
292 scheduler: &Scheduler,
293 kind: BreakKind,
294) -> Result<PostponeOutcome, String> {
295 let s = scheduler.settings.lock().await.clone();
296 if s.strict_mode || !s.postpone_enabled {
297 return Err("postpone disabled".to_string());
298 }
299 let counter_before = {
300 let t = scheduler.timers.lock().await;
301 postpone_counter(&t, kind)
302 };
303 if s.postpone_escalation_enabled
304 && matches!(kind, BreakKind::Micro | BreakKind::Long)
305 && counter_before >= s.postpone_max_count
306 {
307 return Err("postpone exhausted".to_string());
308 }
309 let postpone_secs = effective_postpone_secs(&s, counter_before, kind);
310 {
311 let mut t = scheduler.timers.lock().await;
312 let now = Instant::now();
313 match kind {
314 BreakKind::Micro => {
315 let target = Duration::from_secs(s.micro_interval_secs)
316 .saturating_sub(Duration::from_secs(postpone_secs));
317 t.last_micro = now.checked_sub(target).unwrap_or(now);
318 t.micro_warned = false;
319 t.micro_deferred_since = None;
320 t.micro_postpone_count = t.micro_postpone_count.saturating_add(1);
321 }
322 BreakKind::Long => {
323 let target = Duration::from_secs(s.long_interval_secs)
324 .saturating_sub(Duration::from_secs(postpone_secs));
325 t.last_long = now.checked_sub(target).unwrap_or(now);
326 t.long_warned = false;
327 t.long_deferred_since = None;
328 let micro_target = Duration::from_secs(s.micro_interval_secs)
329 .saturating_sub(Duration::from_secs(postpone_secs));
330 t.last_micro = now.checked_sub(micro_target).unwrap_or(now);
331 t.micro_warned = false;
332 t.micro_deferred_since = None;
333 t.long_postpone_count = t.long_postpone_count.saturating_add(1);
334 }
335 BreakKind::Sleep => {
336 t.last_sleep = Some(now);
337 }
338 }
339 t.last_skipped_or_postponed = Some((kind, now));
340 }
341 {
342 let mut stats = scheduler.stats.lock().await;
343 stats.postponed = stats.postponed.saturating_add(1);
344 }
345 let minutes_logged = (postpone_secs / 60) as u32;
346 scheduler.logger.log(EventPayload::BreakPostponed {
347 kind,
348 minutes: minutes_logged.max(1),
349 });
350 hooks::run_hooks(&s, HookEvent::BreakPostponed, HookContext::with_kind(kind));
351 {
352 let mut t = scheduler.timers.lock().await;
353 t.active_break = None;
354 }
355 if let Ok(mut slot) = scheduler.current_break.lock() {
356 *slot = None;
357 }
358 Ok(PostponeOutcome { postpone_secs })
359}
360
361fn effective_postpone_secs(s: &Settings, counter: u32, kind: BreakKind) -> u64 {
362 let base = (s.postpone_minutes as u64) * 60;
363 if !s.postpone_escalation_enabled || matches!(kind, BreakKind::Sleep) {
364 return base;
365 }
366 let step = s
367 .postpone_escalation_step_secs
368 .saturating_mul(counter as u64);
369 base.saturating_add(step)
370}
371
372pub async fn skip_next_from_cli(
376 app: &AppHandle,
377 scheduler: &Scheduler,
378 kind: BreakKind,
379) -> Result<(), String> {
380 skip_next_break_impl(scheduler, kind).await?;
381 let stats = scheduler.stats.lock().await.clone();
382 let _ = app.emit("stats:changed", &stats);
383 let _ = app.emit("last_break:changed", LastBreakInfo { kind: Some(kind) });
384 Ok(())
385}
386
387pub async fn skip_next_break_impl(scheduler: &Scheduler, kind: BreakKind) -> Result<(), String> {
392 let s = scheduler.settings.lock().await.clone();
393 if s.strict_mode {
394 return Err("strict mode active".to_string());
395 }
396 let now = Instant::now();
397 {
398 let mut t = scheduler.timers.lock().await;
399 match kind {
400 BreakKind::Micro => {
401 t.last_micro = now;
402 t.micro_warned = false;
403 t.micro_deferred_since = None;
404 t.micro_postpone_count = 0;
405 }
406 BreakKind::Long => {
407 t.last_long = now;
408 t.last_micro = now;
409 t.long_warned = false;
410 t.micro_warned = false;
411 t.long_deferred_since = None;
412 t.micro_deferred_since = None;
413 t.long_postpone_count = 0;
414 }
415 BreakKind::Sleep => {
416 t.last_sleep = Some(now);
417 }
418 }
419 t.last_skipped_or_postponed = Some((kind, now));
420 }
421 {
422 let mut stats = scheduler.stats.lock().await;
423 stats.skipped = stats.skipped.saturating_add(1);
424 }
425 scheduler.logger.log(EventPayload::BreakSkipped {
426 kind,
427 source: SkipSource::User,
428 });
429 hooks::run_hooks(&s, HookEvent::BreakSkipped, HookContext::with_kind(kind));
430 Ok(())
431}
432
433#[tauri::command]
435pub async fn skip_next_break(
436 app: AppHandle,
437 scheduler: tauri::State<'_, Scheduler>,
438 kind: BreakKind,
439) -> Result<(), String> {
440 skip_next_from_cli(&app, &scheduler, kind).await
441}
442
443#[tauri::command]
446pub async fn get_postpone_state(
447 scheduler: tauri::State<'_, Scheduler>,
448 kind: BreakKind,
449) -> Result<PostponeState, String> {
450 Ok(compute_postpone_state(&scheduler.settings, &scheduler.timers, kind).await)
451}
452
453async fn compute_postpone_state(
458 settings: &tokio::sync::Mutex<Settings>,
459 timers: &tokio::sync::Mutex<super::super::timers::BreakTimers>,
460 kind: BreakKind,
461) -> PostponeState {
462 let s = settings.lock().await.clone();
470 let count = {
471 let t = timers.lock().await;
472 postpone_counter(&t, kind)
473 };
474 let max = if s.postpone_escalation_enabled && matches!(kind, BreakKind::Micro | BreakKind::Long)
475 {
476 s.postpone_max_count
477 } else {
478 u32::MAX
479 };
480 let remaining = max.saturating_sub(count);
481 PostponeState {
482 count,
483 max,
484 remaining,
485 }
486}
487
488#[tauri::command]
491pub async fn get_last_break_info(
492 scheduler: tauri::State<'_, Scheduler>,
493) -> Result<LastBreakInfo, String> {
494 let t = scheduler.timers.lock().await;
495 Ok(LastBreakInfo {
496 kind: t.last_skipped_or_postponed.map(|(k, _)| k),
497 })
498}
499
500pub async fn resume_last_break_impl(app: &AppHandle, scheduler: &Scheduler) -> Result<(), String> {
505 let stored = {
506 let mut t = scheduler.timers.lock().await;
507 t.last_skipped_or_postponed.take()
508 };
509 let Some((kind, _)) = stored else {
510 return Err("no break to resume".to_string());
511 };
512 let s = scheduler.settings.lock().await.clone();
513 let (duration_secs, enforceable, manual_finish, hints) = match kind {
514 BreakKind::Micro => (
515 s.micro_duration_secs,
516 s.micro_enforceable || s.strict_mode,
517 s.micro_manual_finish,
518 effective_micro_hints(&s),
519 ),
520 BreakKind::Long => (
521 s.long_duration_secs,
522 s.long_enforceable || s.strict_mode,
523 s.long_manual_finish,
524 effective_long_hints(&s),
525 ),
526 BreakKind::Sleep => (s.bedtime_duration_secs, true, false, s.sleep_hints.clone()),
527 };
528 let intensity = scheduler.stats.lock().await.intensity();
529 fire_break(
530 app,
531 &scheduler.current_break,
532 kind,
533 duration_secs,
534 enforceable,
535 s.monitor_placement,
536 is_windowed_mode(kind, &s),
537 manual_finish,
538 s.postpone_enabled && !s.strict_mode,
539 hints,
540 s.hint_rotate_seconds,
541 if s.break_health_enabled {
542 intensity
543 } else {
544 0.0
545 },
546 );
547 scheduler.logger.log(EventPayload::BreakResumed { kind });
548 hooks::run_hooks(
549 &s,
550 HookEvent::BreakStart,
551 HookContext::with_kind_duration(kind, duration_secs),
552 );
553 {
554 let mut t = scheduler.timers.lock().await;
555 let now = Instant::now();
556 match kind {
557 BreakKind::Micro => {
558 t.last_micro = now;
559 t.micro_warned = false;
560 }
561 BreakKind::Long => {
562 t.last_long = now;
563 t.last_micro = now;
564 t.long_warned = false;
565 t.micro_warned = false;
566 }
567 BreakKind::Sleep => {
568 t.last_sleep = Some(now);
569 }
570 }
571 t.active_break = Some(kind);
572 }
573 let _ = app.emit("last_break:changed", LastBreakInfo { kind: None });
574 Ok(())
575}
576
577#[tauri::command]
579pub async fn resume_last_break(
580 app: AppHandle,
581 scheduler: tauri::State<'_, Scheduler>,
582) -> Result<(), String> {
583 resume_last_break_impl(&app, scheduler.inner()).await
584}
585
586#[cfg(test)]
587mod tests {
588 use super::super::super::screen_time::ScreenTimeState;
589 use super::super::super::timers::BreakTimers;
590 use super::super::super::BreakStats;
591 use super::*;
592 use crate::config::{Profile, DEFAULT_PROFILE_NAME};
593 use crate::stats::Logger;
594 use crate::test_support::{temp_dir, TempDir};
595 use std::sync::atomic::{AtomicBool, AtomicU8};
596 use std::sync::Arc;
597 use tokio::sync::Mutex;
598
599 fn settings_with_postpone(
600 escalation: bool,
601 minutes: u32,
602 step: u64,
603 max_count: u32,
604 ) -> Settings {
605 Settings {
606 postpone_escalation_enabled: escalation,
607 postpone_minutes: minutes,
608 postpone_escalation_step_secs: step,
609 postpone_max_count: max_count,
610 ..Settings::default()
611 }
612 }
613
614 #[test]
615 fn effective_postpone_secs_no_escalation_when_disabled() {
616 let s = settings_with_postpone(false, 5, 120, 3);
617 assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Micro), 300);
618 assert_eq!(effective_postpone_secs(&s, 3, BreakKind::Micro), 300);
619 }
620
621 #[test]
622 fn effective_postpone_secs_grows_with_counter() {
623 let s = settings_with_postpone(true, 5, 120, 3);
624 assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Micro), 300);
625 assert_eq!(effective_postpone_secs(&s, 1, BreakKind::Micro), 420);
626 assert_eq!(effective_postpone_secs(&s, 2, BreakKind::Micro), 540);
627 assert_eq!(effective_postpone_secs(&s, 1, BreakKind::Long), 420);
628 }
629
630 #[test]
631 fn test_break_enforceable_micro_off_when_no_strict_no_micro_enforceable() {
632 let s = Settings {
633 strict_mode: false,
634 micro_enforceable: false,
635 ..Settings::default()
636 };
637 assert!(!test_break_enforceable(BreakKind::Micro, &s));
638 }
639
640 #[test]
641 fn test_break_enforceable_micro_true_when_micro_enforceable() {
642 let s = Settings {
643 strict_mode: false,
644 micro_enforceable: true,
645 ..Settings::default()
646 };
647 assert!(test_break_enforceable(BreakKind::Micro, &s));
648 }
649
650 #[test]
651 fn test_break_enforceable_micro_true_when_strict_mode() {
652 let s = Settings {
653 strict_mode: true,
654 micro_enforceable: false,
655 ..Settings::default()
656 };
657 assert!(test_break_enforceable(BreakKind::Micro, &s));
658 }
659
660 #[test]
661 fn test_break_enforceable_long_mirrors_micro() {
662 let off = Settings {
663 strict_mode: false,
664 long_enforceable: false,
665 ..Settings::default()
666 };
667 assert!(!test_break_enforceable(BreakKind::Long, &off));
668
669 let opt_in = Settings {
670 strict_mode: false,
671 long_enforceable: true,
672 ..Settings::default()
673 };
674 assert!(test_break_enforceable(BreakKind::Long, &opt_in));
675
676 let strict = Settings {
677 strict_mode: true,
678 long_enforceable: false,
679 ..Settings::default()
680 };
681 assert!(test_break_enforceable(BreakKind::Long, &strict));
682 }
683
684 #[test]
685 fn test_break_enforceable_sleep_always_true() {
686 let lax = Settings {
687 strict_mode: false,
688 micro_enforceable: false,
689 long_enforceable: false,
690 ..Settings::default()
691 };
692 assert!(test_break_enforceable(BreakKind::Sleep, &lax));
693
694 let strict = Settings {
695 strict_mode: true,
696 ..Settings::default()
697 };
698 assert!(test_break_enforceable(BreakKind::Sleep, &strict));
699 }
700
701 #[test]
702 fn effective_postpone_secs_sleep_never_escalates() {
703 let s = settings_with_postpone(true, 5, 120, 3);
704 assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Sleep), 300);
705 assert_eq!(effective_postpone_secs(&s, 3, BreakKind::Sleep), 300);
706 }
707
708 use tokio::time::{timeout, Duration as TokioDuration};
716
717 fn timers_with_postpone(micro: u32, long: u32) -> BreakTimers {
718 let mut t = BreakTimers::new();
719 t.micro_postpone_count = micro;
720 t.long_postpone_count = long;
721 t
722 }
723
724 #[tokio::test]
725 async fn compute_postpone_state_does_not_deadlock_concurrent_callers() {
726 let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
727 let timers = Arc::new(Mutex::new(timers_with_postpone(2, 1)));
728
729 let mut handles = Vec::new();
730 for kind in [BreakKind::Micro, BreakKind::Long] {
731 for _ in 0..16 {
732 let s = settings.clone();
733 let t = timers.clone();
734 handles.push(tokio::spawn(async move {
735 compute_postpone_state(&s, &t, kind).await
736 }));
737 }
738 }
739
740 for h in handles {
743 let state = timeout(TokioDuration::from_secs(5), h)
744 .await
745 .expect("compute_postpone_state should not deadlock under concurrent calls")
746 .unwrap();
747 assert_eq!(state.remaining, state.max.saturating_sub(state.count));
749 }
750 }
751
752 #[tokio::test]
753 async fn compute_postpone_state_returns_expected_snapshot() {
754 let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
755 let timers = Arc::new(Mutex::new(timers_with_postpone(2, 1)));
756
757 let micro = compute_postpone_state(&settings, &timers, BreakKind::Micro).await;
758 assert_eq!(micro.count, 2);
759 assert_eq!(micro.max, 3);
760 assert_eq!(micro.remaining, 1);
761
762 let long = compute_postpone_state(&settings, &timers, BreakKind::Long).await;
763 assert_eq!(long.count, 1);
764 assert_eq!(long.max, 3);
765 assert_eq!(long.remaining, 2);
766
767 let settings = Arc::new(Mutex::new(settings_with_postpone(false, 5, 120, 3)));
769 let micro_no_cap = compute_postpone_state(&settings, &timers, BreakKind::Micro).await;
770 assert_eq!(micro_no_cap.max, u32::MAX);
771 assert_eq!(micro_no_cap.remaining, u32::MAX - 2);
772
773 let sleep = compute_postpone_state(&settings, &timers, BreakKind::Sleep).await;
775 assert_eq!(sleep.count, 0);
776 assert_eq!(sleep.max, u32::MAX);
777 }
778
779 fn build_test_scheduler(settings: Settings) -> (TempDir, Scheduler) {
784 let dir = temp_dir();
785 let config_path = dir.path().join("settings.json");
786 let pause_path = dir.path().join("pause.json");
787 let events_path = dir.path().join("events.jsonl");
788 let screen_time_path = dir.path().join("screen_time.json");
789 let logger = Logger::spawn(events_path.clone());
790 let sched = Scheduler {
791 settings: Arc::new(Mutex::new(settings)),
792 pause_state: Arc::new(Mutex::new(PauseState::Running)),
793 camera_active: Arc::new(AtomicBool::new(false)),
794 video_active: Arc::new(AtomicBool::new(false)),
795 auto_suppress_reason: Arc::new(AtomicU8::new(0)),
796 config_path,
797 pause_path,
798 events_path,
799 screen_time_path,
800 timers: Arc::new(Mutex::new(BreakTimers::new())),
801 stats: Arc::new(Mutex::new(BreakStats::default())),
802 screen_time: Arc::new(Mutex::new(ScreenTimeState::from_snapshot(
803 crate::screen_time_store::ScreenTimeSnapshot::default(),
804 "1970-01-01",
805 ))),
806 current_break: Arc::new(std::sync::Mutex::new(None)),
807 logger,
808 profiles: Arc::new(Mutex::new(vec![Profile {
809 name: DEFAULT_PROFILE_NAME.to_string(),
810 settings: Settings::default(),
811 }])),
812 active_profile_name: Arc::new(Mutex::new(DEFAULT_PROFILE_NAME.to_string())),
813 hook_dialog_busy: Arc::new(AtomicBool::new(false)),
814 };
815 (dir, sched)
816 }
817
818 #[tokio::test]
819 async fn pause_some_secs_transitions_running_to_timed_pause() {
820 let (_dir, sched) = build_test_scheduler(Settings::default());
821 pause_impl(&sched, Some(900)).await;
822 let state = sched.pause_state.lock().await.clone();
823 match state {
824 PauseState::PausedUntil(Some(deadline)) => {
825 let remaining = deadline.saturating_duration_since(Instant::now());
826 assert!(remaining.as_secs() >= 895 && remaining.as_secs() <= 900);
827 }
828 other => panic!("expected PausedUntil(Some), got {other:?}"),
829 }
830 let snap = crate::pause_store::load(&sched.pause_path);
832 assert!(snap.paused);
833 assert!(snap.until_epoch_secs.is_some());
834 }
835
836 #[tokio::test]
837 async fn pause_none_transitions_running_to_indefinite() {
838 let (_dir, sched) = build_test_scheduler(Settings::default());
839 pause_impl(&sched, None).await;
840 assert!(matches!(
841 *sched.pause_state.lock().await,
842 PauseState::PausedUntil(None)
843 ));
844 let snap = crate::pause_store::load(&sched.pause_path);
845 assert!(snap.paused);
846 assert!(snap.until_epoch_secs.is_none());
847 }
848
849 #[tokio::test]
850 async fn resume_from_paused_returns_to_running() {
851 let (_dir, sched) = build_test_scheduler(Settings::default());
852 pause_impl(&sched, Some(60)).await;
853 resume_impl(&sched).await;
854 assert!(matches!(
855 *sched.pause_state.lock().await,
856 PauseState::Running
857 ));
858 let snap = crate::pause_store::load(&sched.pause_path);
859 assert!(!snap.paused);
860 }
861
862 #[tokio::test]
863 async fn postpone_break_bumps_counter_and_returns_delay() {
864 let settings = Settings {
865 postpone_enabled: true,
866 postpone_escalation_enabled: true,
867 postpone_minutes: 5,
868 postpone_escalation_step_secs: 120,
869 postpone_max_count: 3,
870 ..Settings::default()
871 };
872 let (_dir, sched) = build_test_scheduler(settings);
873 let out = postpone_break_impl(&sched, BreakKind::Micro).await.unwrap();
874 assert_eq!(out.postpone_secs, 300);
875 let t = sched.timers.lock().await;
876 assert_eq!(t.micro_postpone_count, 1);
877 assert!(matches!(
878 t.last_skipped_or_postponed,
879 Some((BreakKind::Micro, _))
880 ));
881 drop(t);
882 let out2 = postpone_break_impl(&sched, BreakKind::Micro).await.unwrap();
884 assert_eq!(out2.postpone_secs, 420);
885 assert_eq!(sched.timers.lock().await.micro_postpone_count, 2);
886 }
887
888 #[tokio::test]
889 async fn postpone_break_errors_when_max_reached() {
890 let settings = Settings {
891 postpone_enabled: true,
892 postpone_escalation_enabled: true,
893 postpone_minutes: 5,
894 postpone_escalation_step_secs: 120,
895 postpone_max_count: 2,
896 ..Settings::default()
897 };
898 let (_dir, sched) = build_test_scheduler(settings);
899 postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
900 postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
901 let err = postpone_break_impl(&sched, BreakKind::Long)
902 .await
903 .expect_err("third postpone should hit the cap");
904 assert_eq!(err, "postpone exhausted");
905 }
906
907 #[tokio::test]
908 async fn postpone_break_errors_when_strict_mode_or_disabled() {
909 let strict = Settings {
910 strict_mode: true,
911 postpone_enabled: true,
912 ..Settings::default()
913 };
914 let (_dir, sched) = build_test_scheduler(strict);
915 let err = postpone_break_impl(&sched, BreakKind::Micro)
916 .await
917 .expect_err("strict mode blocks postpone");
918 assert_eq!(err, "postpone disabled");
919
920 let disabled = Settings {
921 strict_mode: false,
922 postpone_enabled: false,
923 ..Settings::default()
924 };
925 let (_dir2, sched2) = build_test_scheduler(disabled);
926 let err = postpone_break_impl(&sched2, BreakKind::Micro)
927 .await
928 .expect_err("postpone_enabled=false blocks postpone");
929 assert_eq!(err, "postpone disabled");
930 }
931
932 #[tokio::test]
933 async fn skip_next_break_resets_anchor_and_increments_stats() {
934 let (_dir, sched) = build_test_scheduler(Settings::default());
935 {
937 let mut t = sched.timers.lock().await;
938 t.last_micro = Instant::now()
939 .checked_sub(Duration::from_secs(3_600))
940 .unwrap_or_else(Instant::now);
941 t.micro_postpone_count = 5;
942 t.micro_warned = true;
943 }
944 skip_next_break_impl(&sched, BreakKind::Micro)
945 .await
946 .unwrap();
947 let t = sched.timers.lock().await;
948 assert_eq!(t.micro_postpone_count, 0);
949 assert!(!t.micro_warned);
950 assert!(t.last_micro.elapsed() < Duration::from_secs(1));
952 assert!(matches!(
953 t.last_skipped_or_postponed,
954 Some((BreakKind::Micro, _))
955 ));
956 drop(t);
957 assert_eq!(sched.stats.lock().await.skipped, 1);
958 }
959
960 #[tokio::test]
961 async fn skip_next_break_errors_in_strict_mode() {
962 let strict = Settings {
963 strict_mode: true,
964 ..Settings::default()
965 };
966 let (_dir, sched) = build_test_scheduler(strict);
967 let err = skip_next_break_impl(&sched, BreakKind::Micro)
968 .await
969 .expect_err("strict mode blocks skip");
970 assert_eq!(err, "strict mode active");
971 }
972
973 #[tokio::test]
974 async fn skip_next_break_long_resets_both_anchors_and_counter() {
975 let (_dir, sched) = build_test_scheduler(Settings::default());
979 {
980 let mut t = sched.timers.lock().await;
981 t.last_long = Instant::now()
982 .checked_sub(Duration::from_secs(3_600))
983 .unwrap_or_else(Instant::now);
984 t.last_micro = Instant::now()
985 .checked_sub(Duration::from_secs(3_600))
986 .unwrap_or_else(Instant::now);
987 t.long_postpone_count = 4;
988 t.long_warned = true;
989 t.micro_warned = true;
990 }
991 skip_next_break_impl(&sched, BreakKind::Long).await.unwrap();
992 let t = sched.timers.lock().await;
993 assert_eq!(t.long_postpone_count, 0);
994 assert!(!t.long_warned);
995 assert!(!t.micro_warned);
996 assert!(t.last_long.elapsed() < Duration::from_secs(1));
997 assert!(t.last_micro.elapsed() < Duration::from_secs(1));
998 assert!(matches!(
999 t.last_skipped_or_postponed,
1000 Some((BreakKind::Long, _))
1001 ));
1002 }
1003
1004 #[tokio::test]
1005 async fn skip_next_break_sleep_sets_last_sleep_marker() {
1006 let (_dir, sched) = build_test_scheduler(Settings::default());
1007 assert!(sched.timers.lock().await.last_sleep.is_none());
1008 skip_next_break_impl(&sched, BreakKind::Sleep)
1009 .await
1010 .unwrap();
1011 let t = sched.timers.lock().await;
1012 assert!(t.last_sleep.is_some());
1013 assert!(matches!(
1014 t.last_skipped_or_postponed,
1015 Some((BreakKind::Sleep, _))
1016 ));
1017 }
1018
1019 #[tokio::test]
1020 async fn postpone_break_long_bumps_long_counter_and_resets_micro_anchor() {
1021 let settings = Settings {
1024 postpone_enabled: true,
1025 postpone_escalation_enabled: true,
1026 postpone_minutes: 5,
1027 postpone_escalation_step_secs: 120,
1028 postpone_max_count: 3,
1029 ..Settings::default()
1030 };
1031 let (_dir, sched) = build_test_scheduler(settings);
1032 let out = postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
1033 assert_eq!(out.postpone_secs, 300);
1034 let t = sched.timers.lock().await;
1035 assert_eq!(t.long_postpone_count, 1);
1036 assert!(!t.micro_warned);
1038 assert!(t.micro_deferred_since.is_none());
1039 assert!(matches!(
1040 t.last_skipped_or_postponed,
1041 Some((BreakKind::Long, _))
1042 ));
1043 }
1044
1045 #[tokio::test]
1046 async fn postpone_break_sleep_records_last_sleep() {
1047 let settings = Settings {
1048 postpone_enabled: true,
1049 postpone_escalation_enabled: false,
1051 postpone_minutes: 5,
1052 ..Settings::default()
1053 };
1054 let (_dir, sched) = build_test_scheduler(settings);
1055 assert!(sched.timers.lock().await.last_sleep.is_none());
1056 postpone_break_impl(&sched, BreakKind::Sleep).await.unwrap();
1057 let t = sched.timers.lock().await;
1058 assert!(t.last_sleep.is_some());
1059 assert!(matches!(
1060 t.last_skipped_or_postponed,
1061 Some((BreakKind::Sleep, _))
1062 ));
1063 }
1064
1065 #[tokio::test]
1066 async fn postpone_break_with_escalation_disabled_ignores_cap() {
1067 let settings = Settings {
1071 postpone_enabled: true,
1072 postpone_escalation_enabled: false,
1073 postpone_minutes: 5,
1074 postpone_escalation_step_secs: 120,
1075 postpone_max_count: 1,
1076 ..Settings::default()
1077 };
1078 let (_dir, sched) = build_test_scheduler(settings);
1079 for _ in 0..3 {
1080 let out = postpone_break_impl(&sched, BreakKind::Micro)
1081 .await
1082 .expect("escalation off uncaps postpone");
1083 assert_eq!(out.postpone_secs, 300, "no escalation = constant 5 min");
1084 }
1085 assert_eq!(sched.timers.lock().await.micro_postpone_count, 3);
1086 }
1087
1088 async fn wait_for_log_substring(path: &std::path::Path, marker: &str) {
1092 let deadline = Instant::now() + Duration::from_secs(2);
1093 while Instant::now() < deadline {
1094 if let Ok(contents) = std::fs::read_to_string(path) {
1095 if contents.contains(marker) {
1096 return;
1097 }
1098 }
1099 tokio::time::sleep(Duration::from_millis(25)).await;
1100 }
1101 }
1102
1103 #[tokio::test]
1104 async fn pause_when_already_paused_does_not_re_fire_hooks() {
1105 let (_dir, sched) = build_test_scheduler(Settings::default());
1108 pause_impl(&sched, Some(60)).await;
1109 wait_for_log_substring(&sched.events_path, "\"type\":\"pause_start\"").await;
1110 pause_impl(&sched, Some(900)).await;
1111 tokio::time::sleep(Duration::from_millis(150)).await;
1114 let log = std::fs::read_to_string(&sched.events_path).unwrap_or_default();
1115 let count = log.matches("\"type\":\"pause_start\"").count();
1116 assert_eq!(
1117 count, 1,
1118 "pause_start only fires on the running→paused edge, got log:\n{log}",
1119 );
1120 }
1121
1122 #[tokio::test]
1123 async fn resume_when_already_running_is_a_noop() {
1124 let (_dir, sched) = build_test_scheduler(Settings::default());
1127 resume_impl(&sched).await;
1128 tokio::time::sleep(Duration::from_millis(150)).await;
1130 let log = std::fs::read_to_string(&sched.events_path).unwrap_or_default();
1131 assert!(
1132 !log.contains("\"type\":\"pause_end\""),
1133 "resume on a Running scheduler must not log pause_end, got log:\n{log}",
1134 );
1135 assert!(matches!(
1137 *sched.pause_state.lock().await,
1138 PauseState::Running
1139 ));
1140 }
1141
1142 #[tokio::test]
1143 async fn compute_postpone_state_sleep_is_uncapped_even_with_escalation_on() {
1144 let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
1148 let timers = Arc::new(Mutex::new(timers_with_postpone(0, 0)));
1149 let sleep = compute_postpone_state(&settings, &timers, BreakKind::Sleep).await;
1150 assert_eq!(sleep.max, u32::MAX);
1151 assert_eq!(sleep.count, 0);
1152 assert_eq!(sleep.remaining, u32::MAX);
1153 }
1154
1155 #[tokio::test]
1156 async fn end_break_completed_resets_postpone_counter_and_clears_last() {
1157 let (_dir, sched) = build_test_scheduler(Settings::default());
1163 {
1164 let mut t = sched.timers.lock().await;
1165 t.active_break = Some(BreakKind::Micro);
1166 t.micro_postpone_count = 2;
1167 t.last_skipped_or_postponed = Some((BreakKind::Micro, Instant::now()));
1168 }
1169 let active_kind = {
1172 let mut t = sched.timers.lock().await;
1173 t.active_break.take()
1174 };
1175 assert_eq!(active_kind, Some(BreakKind::Micro));
1176 let mut t = sched.timers.lock().await;
1177 reset_postpone_counter(&mut t, BreakKind::Micro);
1178 let cleared = clear_last_break(&mut t);
1179 assert!(
1180 cleared,
1181 "clear_last_break returns true when slot was populated"
1182 );
1183 assert_eq!(t.micro_postpone_count, 0);
1184 assert!(t.last_skipped_or_postponed.is_none());
1185 }
1186}