1use std::time::{Duration, Instant};
2
3use tauri::{AppHandle, Emitter, Runtime};
4
5use crate::hooks::{self, HookContext, HookEvent};
6use crate::stats::{EventPayload, Outcome, SkipSource};
7
8use super::super::overlay::{deliver_break, fire_break, hide_overlay_windows};
9use super::super::overlay_watchdog::OVERLAY_ACK;
10use super::super::pause::{persist_pause, PauseInfo, PauseState};
11use super::super::settings::{
12 delivery_for, effective_long_hints, effective_micro_hints, is_windowed_mode,
13 windowed_fraction_for, Settings,
14};
15use super::super::timers::{
16 clear_last_break, postpone_counter, reanchor_intervals_on_resume, reset_postpone_counter,
17};
18use super::super::types::{BreakEvent, BreakKind, LastBreakInfo, PostponeState};
19use super::super::Scheduler;
20
21#[tauri::command]
26pub async fn pause<R: Runtime>(
27 app: AppHandle<R>,
28 scheduler: tauri::State<'_, Scheduler>,
29 duration_secs: Option<u64>,
30) -> Result<(), String> {
31 pause_impl(scheduler.inner(), duration_secs).await;
32 let _ = app.emit("pause:changed", true);
33 Ok(())
34}
35
36#[tauri::command]
39pub async fn resume<R: Runtime>(
40 app: AppHandle<R>,
41 scheduler: tauri::State<'_, Scheduler>,
42) -> Result<(), String> {
43 resume_impl(scheduler.inner()).await;
44 let _ = app.emit("pause:changed", false);
45 Ok(())
46}
47
48pub async fn pause_impl(scheduler: &Scheduler, duration_secs: Option<u64>) {
52 let until = duration_secs.map(|s| Instant::now() + Duration::from_secs(s));
53 let new_state = PauseState::PausedUntil(until);
54 let was_running;
55 {
56 let mut guard = scheduler.pause_state.lock().await;
57 was_running = matches!(*guard, PauseState::Running);
58 *guard = new_state.clone();
59 }
60 persist_pause(&scheduler.pause_path, &new_state);
61 if was_running {
62 scheduler
63 .logger
64 .log(EventPayload::PauseStart { duration_secs });
65 let settings_snapshot = scheduler.settings.lock().await.clone();
66 hooks::run_hooks(
67 &settings_snapshot,
68 HookEvent::PauseStart,
69 HookContext::empty(),
70 );
71 crate::scheduler::exports::deliver_on_event(scheduler, HookEvent::PauseStart);
72 }
73}
74
75pub async fn resume_impl(scheduler: &Scheduler) {
78 let was_paused;
79 {
80 let mut guard = scheduler.pause_state.lock().await;
81 was_paused = !matches!(*guard, PauseState::Running);
82 *guard = PauseState::Running;
83 }
84 persist_pause(&scheduler.pause_path, &PauseState::Running);
85 if was_paused {
86 reanchor_intervals_on_resume(&mut *scheduler.timers.lock().await, Instant::now());
87 scheduler.logger.log(EventPayload::PauseEnd);
88 let settings_snapshot = scheduler.settings.lock().await.clone();
89 hooks::run_hooks(
90 &settings_snapshot,
91 HookEvent::PauseEnd,
92 HookContext::empty(),
93 );
94 crate::scheduler::exports::deliver_on_event(scheduler, HookEvent::PauseEnd);
95 }
96}
97
98#[tauri::command]
102pub async fn get_pause_info(scheduler: tauri::State<'_, Scheduler>) -> Result<PauseInfo, String> {
103 let state = scheduler.pause_state.lock().await;
104 Ok(match &*state {
105 PauseState::Running => PauseInfo {
106 paused: false,
107 remaining_secs: None,
108 },
109 PauseState::PausedUntil(None) => PauseInfo {
110 paused: true,
111 remaining_secs: None,
112 },
113 PauseState::PausedUntil(Some(t)) => {
114 let now = Instant::now();
115 let remaining = if *t > now { (*t - now).as_secs() } else { 0 };
116 PauseInfo {
117 paused: true,
118 remaining_secs: Some(remaining),
119 }
120 }
121 })
122}
123
124pub(crate) fn test_break_enforceable(kind: BreakKind, s: &Settings) -> bool {
129 s.for_kind(kind)
130 .is_none_or(|b| b.enforceable || s.strict_mode)
131}
132
133pub(crate) fn fire_fields(kind: BreakKind, s: &Settings) -> (u64, bool, bool, Vec<String>) {
140 let (duration_secs, manual_finish) = s.duration_and_manual_finish(kind);
141 (
142 duration_secs,
143 test_break_enforceable(kind, s),
144 manual_finish,
145 s.effective_hints(kind),
146 )
147}
148
149pub async fn trigger_break_from_cli<R: Runtime>(
153 app: &AppHandle<R>,
154 scheduler: &Scheduler,
155 kind: BreakKind,
156 duration_secs: u64,
157) {
158 let s = scheduler.settings.lock().await.clone();
159 let (_, enforceable, manual_finish, hints) = fire_fields(kind, &s);
162 let intensity = scheduler.stats.lock().await.intensity();
163 let chore_prompt = scheduler.resolve_chore_prompt(kind).await;
164 let delivery = delivery_for(kind, &s);
165 let resolved = super::super::routines::resolve_routine(kind, &s);
166 deliver_break(
167 app,
168 &scheduler.current_break,
169 BreakEvent {
170 kind,
171 duration_secs,
172 enforceable,
173 manual_finish,
174 postpone_available: s.postpone_available_for(kind),
175 skip_available: s.skip_available_for(kind),
176 hints,
177 hint_rotate_seconds: s.hint_rotate_seconds,
178 health_intensity: if s.break_health_enabled {
179 intensity
180 } else {
181 0.0
182 },
183 routine_steps: resolved.steps,
184 routine_pacing: resolved.pacing,
185 routine_max_step_secs: resolved.max_step_secs,
186 routine_breath: resolved.breath,
187 chore_prompt,
188 },
189 delivery,
190 s.monitor_placement,
191 windowed_fraction_for(kind, &s),
192 );
193 hooks::run_hooks(
194 &s,
195 HookEvent::BreakStart,
196 HookContext::with_kind_duration(kind, duration_secs),
197 );
198 crate::scheduler::exports::deliver_on_event(scheduler, HookEvent::BreakStart);
199}
200
201#[tauri::command]
204pub async fn trigger_test_break<R: Runtime>(
205 app: AppHandle<R>,
206 scheduler: tauri::State<'_, Scheduler>,
207 kind: BreakKind,
208 duration_secs: u64,
209) -> Result<(), String> {
210 trigger_break_from_cli(&app, &scheduler, kind, duration_secs).await;
211 Ok(())
212}
213
214#[tauri::command]
219pub fn notify_overlay_rendered() {
220 OVERLAY_ACK.ack();
221}
222
223#[tauri::command]
229pub async fn end_break<R: Runtime>(
230 app: AppHandle<R>,
231 scheduler: tauri::State<'_, Scheduler>,
232 reason: Option<String>,
233) -> Result<(), String> {
234 let reason = reason.unwrap_or_else(|| "completed".to_string());
235 log::info!("scheduler: break ended (reason={reason})");
236 {
237 let mut stats = scheduler.stats.lock().await;
238 match reason.as_str() {
239 "completed" => stats.taken = stats.taken.saturating_add(1),
240 "dismissed" => stats.skipped = stats.skipped.saturating_add(1),
241 "postponed" => stats.postponed = stats.postponed.saturating_add(1),
242 _ => {}
243 }
244 }
245
246 let active_kind = {
247 let mut t = scheduler.timers.lock().await;
248 t.active_break.take()
249 };
250 if let Some(kind) = active_kind {
251 if reason == "completed" {
252 let mut t = scheduler.timers.lock().await;
253 reset_postpone_counter(&mut t, kind);
254 let cleared = clear_last_break(&mut t);
255 drop(t);
256 if cleared {
257 let _ = app.emit("last_break:changed", LastBreakInfo { kind: None });
258 }
259 }
260 let outcome = match reason.as_str() {
261 "dismissed" => Some(Outcome::Dismissed),
262 "completed" => Some(Outcome::Completed),
263 _ => None,
264 };
265 if let Some(o) = outcome {
266 scheduler
267 .logger
268 .log(EventPayload::BreakEnd { kind, outcome: o });
269 }
270 if matches!(reason.as_str(), "completed" | "dismissed") {
271 let settings_snapshot = scheduler.settings.lock().await.clone();
272 hooks::run_hooks(
273 &settings_snapshot,
274 HookEvent::BreakEnd,
275 HookContext::with_kind_outcome(kind, reason.clone()),
276 );
277 crate::scheduler::exports::deliver_on_event(scheduler.inner(), HookEvent::BreakEnd);
278 }
279 }
280
281 *super::super::lock_current_break(&scheduler.current_break) = None;
282 OVERLAY_ACK.ack();
286 crate::media::on_break_end();
289 hide_overlay_windows(&app);
290 let _ = app.emit("break:end", ());
291 let stats = scheduler.stats.lock().await.clone();
292 let _ = app.emit("stats:changed", &stats);
293 Ok(())
294}
295
296#[tauri::command]
303pub async fn postpone_break<R: Runtime>(
304 app: AppHandle<R>,
305 scheduler: tauri::State<'_, Scheduler>,
306 kind: BreakKind,
307) -> Result<(), String> {
308 postpone_break_impl(scheduler.inner(), kind).await?;
309 OVERLAY_ACK.ack();
310 hide_overlay_windows(&app);
311 let _ = app.emit("break:end", ());
312 let _ = app.emit("last_break:changed", LastBreakInfo { kind: Some(kind) });
313 Ok(())
314}
315
316#[derive(Debug, Clone, Copy)]
319pub struct PostponeOutcome {
320 #[allow(dead_code)]
321 pub postpone_secs: u64,
322}
323
324pub async fn postpone_break_impl(
328 scheduler: &Scheduler,
329 kind: BreakKind,
330) -> Result<PostponeOutcome, String> {
331 let s = scheduler.settings.lock().await.clone();
332 if !s.postpone_available_for(kind) {
333 return Err("postpone disabled".to_string());
334 }
335 let counter_before = {
336 let t = scheduler.timers.lock().await;
337 postpone_counter(&t, kind)
338 };
339 if s.postpone_escalation_enabled
340 && matches!(kind, BreakKind::Micro | BreakKind::Long)
341 && counter_before >= s.postpone_max_count
342 {
343 return Err("postpone exhausted".to_string());
344 }
345 let postpone_secs = effective_postpone_secs(&s, counter_before, kind);
346 {
347 let mut t = scheduler.timers.lock().await;
348 let now = Instant::now();
349 match kind {
350 BreakKind::Micro => {
351 let target = Duration::from_secs(s.micro_interval_secs)
352 .saturating_sub(Duration::from_secs(postpone_secs));
353 t.last_micro = now.checked_sub(target).unwrap_or(now);
354 t.micro_warned = false;
355 t.micro_deferred_since = None;
356 t.micro_postpone_count = t.micro_postpone_count.saturating_add(1);
357 }
358 BreakKind::Long => {
359 let target = Duration::from_secs(s.long_interval_secs)
360 .saturating_sub(Duration::from_secs(postpone_secs));
361 t.last_long = now.checked_sub(target).unwrap_or(now);
362 t.long_warned = false;
363 t.long_deferred_since = None;
364 let micro_target = Duration::from_secs(s.micro_interval_secs)
365 .saturating_sub(Duration::from_secs(postpone_secs));
366 t.last_micro = now.checked_sub(micro_target).unwrap_or(now);
367 t.micro_warned = false;
368 t.micro_deferred_since = None;
369 t.long_postpone_count = t.long_postpone_count.saturating_add(1);
370 }
371 BreakKind::Sleep => {
372 t.last_sleep = Some(now);
373 }
374 }
375 t.last_skipped_or_postponed = Some((kind, now));
376 }
377 {
378 let mut stats = scheduler.stats.lock().await;
379 stats.postponed = stats.postponed.saturating_add(1);
380 }
381 let minutes_logged = (postpone_secs / 60) as u32;
382 scheduler.logger.log(EventPayload::BreakPostponed {
383 kind,
384 minutes: minutes_logged.max(1),
385 });
386 hooks::run_hooks(&s, HookEvent::BreakPostponed, HookContext::with_kind(kind));
387 crate::scheduler::exports::deliver_on_event(scheduler, HookEvent::BreakPostponed);
388 {
389 let mut t = scheduler.timers.lock().await;
390 t.active_break = None;
391 }
392 *super::super::lock_current_break(&scheduler.current_break) = None;
393 Ok(PostponeOutcome { postpone_secs })
394}
395
396fn effective_postpone_secs(s: &Settings, counter: u32, kind: BreakKind) -> u64 {
397 let base = (s.postpone_minutes as u64) * 60;
398 if !s.postpone_escalation_enabled || matches!(kind, BreakKind::Sleep) {
399 return base;
400 }
401 let step = s
402 .postpone_escalation_step_secs
403 .saturating_mul(counter as u64);
404 base.saturating_add(step)
405}
406
407pub async fn skip_next_from_cli<R: Runtime>(
411 app: &AppHandle<R>,
412 scheduler: &Scheduler,
413 kind: BreakKind,
414) -> Result<(), String> {
415 skip_next_break_impl(scheduler, kind).await?;
416 let stats = scheduler.stats.lock().await.clone();
417 let _ = app.emit("stats:changed", &stats);
418 let _ = app.emit("last_break:changed", LastBreakInfo { kind: Some(kind) });
419 Ok(())
420}
421
422pub async fn skip_next_break_impl(scheduler: &Scheduler, kind: BreakKind) -> Result<(), String> {
427 let s = scheduler.settings.lock().await.clone();
428 if s.strict_mode {
429 return Err("strict mode active".to_string());
430 }
431 let now = Instant::now();
432 {
433 let mut t = scheduler.timers.lock().await;
434 match kind {
435 BreakKind::Micro => {
436 t.last_micro = now;
437 t.micro_warned = false;
438 t.micro_deferred_since = None;
439 t.micro_postpone_count = 0;
440 }
441 BreakKind::Long => {
442 t.last_long = now;
443 t.last_micro = now;
444 t.long_warned = false;
445 t.micro_warned = false;
446 t.long_deferred_since = None;
447 t.micro_deferred_since = None;
448 t.long_postpone_count = 0;
449 }
450 BreakKind::Sleep => {
451 t.last_sleep = Some(now);
452 }
453 }
454 t.last_skipped_or_postponed = Some((kind, now));
455 }
456 {
457 let mut stats = scheduler.stats.lock().await;
458 stats.skipped = stats.skipped.saturating_add(1);
459 }
460 scheduler.logger.log(EventPayload::BreakSkipped {
461 kind,
462 source: SkipSource::User,
463 });
464 hooks::run_hooks(&s, HookEvent::BreakSkipped, HookContext::with_kind(kind));
465 crate::scheduler::exports::deliver_on_event(scheduler, HookEvent::BreakSkipped);
466 Ok(())
467}
468
469#[tauri::command]
471pub async fn skip_next_break<R: Runtime>(
472 app: AppHandle<R>,
473 scheduler: tauri::State<'_, Scheduler>,
474 kind: BreakKind,
475) -> Result<(), String> {
476 skip_next_from_cli(&app, &scheduler, kind).await
477}
478
479#[tauri::command]
482pub async fn get_postpone_state(
483 scheduler: tauri::State<'_, Scheduler>,
484 kind: BreakKind,
485) -> Result<PostponeState, String> {
486 Ok(compute_postpone_state(&scheduler.settings, &scheduler.timers, kind).await)
487}
488
489async fn compute_postpone_state(
494 settings: &tokio::sync::Mutex<Settings>,
495 timers: &tokio::sync::Mutex<super::super::timers::BreakTimers>,
496 kind: BreakKind,
497) -> PostponeState {
498 let s = settings.lock().await.clone();
506 let count = {
507 let t = timers.lock().await;
508 postpone_counter(&t, kind)
509 };
510 let max = if s.postpone_escalation_enabled && matches!(kind, BreakKind::Micro | BreakKind::Long)
511 {
512 s.postpone_max_count
513 } else {
514 u32::MAX
515 };
516 let remaining = max.saturating_sub(count);
517 PostponeState {
518 count,
519 max,
520 remaining,
521 }
522}
523
524#[tauri::command]
527pub async fn get_last_break_info(
528 scheduler: tauri::State<'_, Scheduler>,
529) -> Result<LastBreakInfo, String> {
530 let t = scheduler.timers.lock().await;
531 Ok(LastBreakInfo {
532 kind: t.last_skipped_or_postponed.map(|(k, _)| k),
533 })
534}
535
536fn resume_break_event(kind: BreakKind, s: &Settings, intensity: f32) -> BreakEvent {
541 let (duration_secs, enforceable, manual_finish, hints) = match kind {
542 BreakKind::Micro => (
543 s.micro_duration_secs,
544 s.micro_enforceable || s.strict_mode,
545 s.micro_manual_finish,
546 effective_micro_hints(s),
547 ),
548 BreakKind::Long => (
549 s.long_duration_secs,
550 s.long_enforceable || s.strict_mode,
551 s.long_manual_finish,
552 effective_long_hints(s),
553 ),
554 BreakKind::Sleep => (s.bedtime_duration_secs, true, false, s.sleep_hints.clone()),
555 };
556 let resolved = super::super::routines::resolve_routine(kind, s);
557 BreakEvent {
558 kind,
559 duration_secs,
560 enforceable,
561 manual_finish,
562 postpone_available: s.postpone_available_for(kind),
563 skip_available: s.skip_available_for(kind),
564 hints,
565 hint_rotate_seconds: s.hint_rotate_seconds,
566 health_intensity: if s.break_health_enabled {
567 intensity
568 } else {
569 0.0
570 },
571 routine_steps: resolved.steps,
572 routine_pacing: resolved.pacing,
573 routine_max_step_secs: resolved.max_step_secs,
574 routine_breath: resolved.breath,
575 chore_prompt: None,
579 }
580}
581
582pub async fn resume_last_break_impl<R: Runtime>(
583 app: &AppHandle<R>,
584 scheduler: &Scheduler,
585) -> Result<(), String> {
586 let stored = {
587 let mut t = scheduler.timers.lock().await;
588 t.last_skipped_or_postponed.take()
589 };
590 let Some((kind, _)) = stored else {
591 return Err("no break to resume".to_string());
592 };
593 let s = scheduler.settings.lock().await.clone();
594 let intensity = scheduler.stats.lock().await.intensity();
595 let event = resume_break_event(kind, &s, intensity);
596 let duration_secs = event.duration_secs;
597 fire_break(
598 app,
599 &scheduler.current_break,
600 event,
601 s.monitor_placement,
602 is_windowed_mode(kind, &s),
603 windowed_fraction_for(kind, &s),
604 );
605 scheduler.logger.log(EventPayload::BreakResumed { kind });
606 hooks::run_hooks(
607 &s,
608 HookEvent::BreakStart,
609 HookContext::with_kind_duration(kind, duration_secs),
610 );
611 crate::scheduler::exports::deliver_on_event(scheduler, HookEvent::BreakStart);
612 {
613 let mut t = scheduler.timers.lock().await;
614 let now = Instant::now();
615 match kind {
616 BreakKind::Micro => {
617 t.last_micro = now;
618 t.micro_warned = false;
619 }
620 BreakKind::Long => {
621 t.last_long = now;
622 t.last_micro = now;
623 t.long_warned = false;
624 t.micro_warned = false;
625 }
626 BreakKind::Sleep => {
627 t.last_sleep = Some(now);
628 }
629 }
630 t.active_break = Some(kind);
631 }
632 let _ = app.emit("last_break:changed", LastBreakInfo { kind: None });
633 Ok(())
634}
635
636#[tauri::command]
638pub async fn resume_last_break<R: Runtime>(
639 app: AppHandle<R>,
640 scheduler: tauri::State<'_, Scheduler>,
641) -> Result<(), String> {
642 resume_last_break_impl(&app, scheduler.inner()).await
643}
644
645pub async fn start_long_break_now_impl<R: Runtime>(app: &AppHandle<R>, scheduler: &Scheduler) {
653 let s = scheduler.settings.lock().await.clone();
654 super::super::run_loop::fire_scheduled_break(app, scheduler, &s, BreakKind::Long, None).await;
655}
656
657#[tauri::command]
660pub async fn start_long_break_now<R: Runtime>(
661 app: AppHandle<R>,
662 scheduler: tauri::State<'_, Scheduler>,
663) -> Result<(), String> {
664 start_long_break_now_impl(&app, scheduler.inner()).await;
665 Ok(())
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::scheduler::timers::BreakTimers;
672 use crate::test_support::test_scheduler;
673 use std::sync::Arc;
674 use tokio::sync::Mutex;
675
676 fn settings_with_postpone(
677 escalation: bool,
678 minutes: u32,
679 step: u64,
680 max_count: u32,
681 ) -> Settings {
682 Settings {
683 postpone_escalation_enabled: escalation,
684 postpone_minutes: minutes,
685 postpone_escalation_step_secs: step,
686 postpone_max_count: max_count,
687 ..Settings::default()
688 }
689 }
690
691 #[test]
692 fn effective_postpone_secs_no_escalation_when_disabled() {
693 let s = settings_with_postpone(false, 5, 120, 3);
694 assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Micro), 300);
695 assert_eq!(effective_postpone_secs(&s, 3, BreakKind::Micro), 300);
696 }
697
698 #[test]
699 fn effective_postpone_secs_grows_with_counter() {
700 let s = settings_with_postpone(true, 5, 120, 3);
701 assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Micro), 300);
702 assert_eq!(effective_postpone_secs(&s, 1, BreakKind::Micro), 420);
703 assert_eq!(effective_postpone_secs(&s, 2, BreakKind::Micro), 540);
704 assert_eq!(effective_postpone_secs(&s, 1, BreakKind::Long), 420);
705 }
706
707 #[test]
708 fn test_break_enforceable_micro_off_when_no_strict_no_micro_enforceable() {
709 let s = Settings {
710 strict_mode: false,
711 micro_enforceable: false,
712 ..Settings::default()
713 };
714 assert!(!test_break_enforceable(BreakKind::Micro, &s));
715 }
716
717 #[test]
718 fn test_break_enforceable_micro_true_when_micro_enforceable() {
719 let s = Settings {
720 strict_mode: false,
721 micro_enforceable: true,
722 ..Settings::default()
723 };
724 assert!(test_break_enforceable(BreakKind::Micro, &s));
725 }
726
727 #[test]
728 fn test_break_enforceable_micro_true_when_strict_mode() {
729 let s = Settings {
730 strict_mode: true,
731 micro_enforceable: false,
732 ..Settings::default()
733 };
734 assert!(test_break_enforceable(BreakKind::Micro, &s));
735 }
736
737 #[test]
738 fn test_break_enforceable_long_mirrors_micro() {
739 let off = Settings {
740 strict_mode: false,
741 long_enforceable: false,
742 ..Settings::default()
743 };
744 assert!(!test_break_enforceable(BreakKind::Long, &off));
745
746 let opt_in = Settings {
747 strict_mode: false,
748 long_enforceable: true,
749 ..Settings::default()
750 };
751 assert!(test_break_enforceable(BreakKind::Long, &opt_in));
752
753 let strict = Settings {
754 strict_mode: true,
755 long_enforceable: false,
756 ..Settings::default()
757 };
758 assert!(test_break_enforceable(BreakKind::Long, &strict));
759 }
760
761 #[test]
762 fn test_break_enforceable_sleep_always_true() {
763 let lax = Settings {
764 strict_mode: false,
765 micro_enforceable: false,
766 long_enforceable: false,
767 ..Settings::default()
768 };
769 assert!(test_break_enforceable(BreakKind::Sleep, &lax));
770
771 let strict = Settings {
772 strict_mode: true,
773 ..Settings::default()
774 };
775 assert!(test_break_enforceable(BreakKind::Sleep, &strict));
776 }
777
778 #[test]
779 #[allow(clippy::field_reassign_with_default)]
780 fn fire_fields_draws_per_kind_micro_long() {
781 let mut s = Settings::default();
782 s.micro_duration_secs = 11;
783 s.micro_manual_finish = true;
784 s.long_duration_secs = 22;
785 s.long_manual_finish = false;
786 s.long_enforceable = true;
787 s.rebuild_derived();
788
789 let (dur, enforceable, manual, hints) = fire_fields(BreakKind::Micro, &s);
790 assert_eq!(dur, 11);
791 assert!(!enforceable); assert!(manual);
793 assert_eq!(hints, s.effective_hints(BreakKind::Micro));
794
795 let (dur, enforceable, manual, _) = fire_fields(BreakKind::Long, &s);
796 assert_eq!(dur, 22);
797 assert!(enforceable);
798 assert!(!manual);
799 }
800
801 #[test]
802 fn fire_fields_sleep_uses_bedtime_duration_and_is_enforceable() {
803 let s = Settings {
804 bedtime_duration_secs: 45,
805 ..Settings::default()
806 };
807 let (dur, enforceable, manual, hints) = fire_fields(BreakKind::Sleep, &s);
808 assert_eq!(dur, 45);
809 assert!(enforceable);
810 assert!(!manual);
811 assert_eq!(hints, s.sleep_hints);
812 }
813
814 #[test]
815 #[allow(clippy::field_reassign_with_default)]
816 fn fire_fields_enforceable_follows_strict_mode() {
817 let mut s = Settings::default();
818 s.strict_mode = true;
819 let (_, enforceable, _, _) = fire_fields(BreakKind::Micro, &s);
820 assert!(enforceable, "strict mode forces enforceable");
821 }
822
823 #[test]
824 fn effective_postpone_secs_sleep_never_escalates() {
825 let s = settings_with_postpone(true, 5, 120, 3);
826 assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Sleep), 300);
827 assert_eq!(effective_postpone_secs(&s, 3, BreakKind::Sleep), 300);
828 }
829
830 use tokio::time::{timeout, Duration as TokioDuration};
838
839 fn timers_with_postpone(micro: u32, long: u32) -> BreakTimers {
840 let mut t = BreakTimers::new();
841 t.micro_postpone_count = micro;
842 t.long_postpone_count = long;
843 t
844 }
845
846 #[tokio::test]
847 async fn compute_postpone_state_does_not_deadlock_concurrent_callers() {
848 let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
849 let timers = Arc::new(Mutex::new(timers_with_postpone(2, 1)));
850
851 let mut handles = Vec::new();
852 for kind in [BreakKind::Micro, BreakKind::Long] {
853 for _ in 0..16 {
854 let s = settings.clone();
855 let t = timers.clone();
856 handles.push(tokio::spawn(async move {
857 compute_postpone_state(&s, &t, kind).await
858 }));
859 }
860 }
861
862 for h in handles {
865 let state = timeout(TokioDuration::from_secs(5), h)
866 .await
867 .expect("compute_postpone_state should not deadlock under concurrent calls")
868 .unwrap();
869 assert_eq!(state.remaining, state.max.saturating_sub(state.count));
871 }
872 }
873
874 #[tokio::test]
875 async fn compute_postpone_state_returns_expected_snapshot() {
876 let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
877 let timers = Arc::new(Mutex::new(timers_with_postpone(2, 1)));
878
879 let micro = compute_postpone_state(&settings, &timers, BreakKind::Micro).await;
880 assert_eq!(micro.count, 2);
881 assert_eq!(micro.max, 3);
882 assert_eq!(micro.remaining, 1);
883
884 let long = compute_postpone_state(&settings, &timers, BreakKind::Long).await;
885 assert_eq!(long.count, 1);
886 assert_eq!(long.max, 3);
887 assert_eq!(long.remaining, 2);
888
889 let settings = Arc::new(Mutex::new(settings_with_postpone(false, 5, 120, 3)));
891 let micro_no_cap = compute_postpone_state(&settings, &timers, BreakKind::Micro).await;
892 assert_eq!(micro_no_cap.max, u32::MAX);
893 assert_eq!(micro_no_cap.remaining, u32::MAX - 2);
894
895 let sleep = compute_postpone_state(&settings, &timers, BreakKind::Sleep).await;
897 assert_eq!(sleep.count, 0);
898 assert_eq!(sleep.max, u32::MAX);
899 }
900
901 #[tokio::test]
902 async fn pause_some_secs_transitions_running_to_timed_pause() {
903 let (_dir, sched) = test_scheduler(Settings::default());
904 pause_impl(&sched, Some(900)).await;
905 let state = sched.pause_state.lock().await.clone();
906 match state {
907 PauseState::PausedUntil(Some(deadline)) => {
908 let remaining = deadline.saturating_duration_since(Instant::now());
909 assert!(remaining.as_secs() >= 895 && remaining.as_secs() <= 900);
910 }
911 other => panic!("expected PausedUntil(Some), got {other:?}"),
912 }
913 let snap = crate::pause_store::load(&sched.pause_path);
915 assert!(snap.paused);
916 assert!(snap.until_epoch_secs.is_some());
917 }
918
919 #[tokio::test]
920 async fn pause_none_transitions_running_to_indefinite() {
921 let (_dir, sched) = test_scheduler(Settings::default());
922 pause_impl(&sched, None).await;
923 assert!(matches!(
924 *sched.pause_state.lock().await,
925 PauseState::PausedUntil(None)
926 ));
927 let snap = crate::pause_store::load(&sched.pause_path);
928 assert!(snap.paused);
929 assert!(snap.until_epoch_secs.is_none());
930 }
931
932 #[tokio::test]
933 async fn resume_from_paused_returns_to_running() {
934 let (_dir, sched) = test_scheduler(Settings::default());
935 pause_impl(&sched, Some(60)).await;
936 resume_impl(&sched).await;
937 assert!(matches!(
938 *sched.pause_state.lock().await,
939 PauseState::Running
940 ));
941 let snap = crate::pause_store::load(&sched.pause_path);
942 assert!(!snap.paused);
943 }
944
945 #[tokio::test]
946 async fn resume_reanchors_interval_clocks_so_no_break_fires_immediately() {
947 let settings = Settings {
952 micro_interval_secs: 1_200,
953 long_interval_secs: 1_800,
954 ..Settings::default()
955 };
956 let (_dir, sched) = test_scheduler(settings);
957 let stale = Instant::now();
962 {
963 let mut t = sched.timers.lock().await;
964 t.last_micro = stale;
965 t.last_long = stale;
966 }
967 pause_impl(&sched, Some(60)).await;
968 resume_impl(&sched).await;
969
970 let t = sched.timers.lock().await;
971 assert!(
972 t.last_micro > stale,
973 "micro anchor must move forward to the resume instant"
974 );
975 assert!(t.last_long > stale, "long anchor must move forward too");
976 assert!(
977 !crate::scheduler::timers::interval_break_due(
978 true,
979 true,
980 t.last_micro,
981 1_200,
982 false,
983 t.last_micro
984 ),
985 "no micro break may be due the instant we resume"
986 );
987 assert!(
988 !crate::scheduler::timers::interval_break_due(
989 true,
990 true,
991 t.last_long,
992 1_800,
993 false,
994 t.last_long
995 ),
996 "no long break may be due the instant we resume"
997 );
998 assert!(
999 crate::scheduler::timers::interval_break_due(
1000 true,
1001 true,
1002 t.last_micro,
1003 1_200,
1004 false,
1005 t.last_micro + Duration::from_secs(1_200)
1006 ),
1007 "next micro break is due exactly one interval after resume"
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn resume_preserves_sleep_and_fixed_time_state() {
1013 let (_dir, sched) = test_scheduler(Settings::default());
1014 let sleep_at = Instant::now();
1015 {
1016 let mut t = sched.timers.lock().await;
1017 t.last_sleep = Some(sleep_at);
1018 t.last_micro_fixed_fire = Some(("2026-06-05".into(), 540));
1019 t.last_long_fixed_fire = Some(("2026-06-05".into(), 600));
1020 }
1021 pause_impl(&sched, Some(60)).await;
1022 resume_impl(&sched).await;
1023
1024 let t = sched.timers.lock().await;
1025 assert_eq!(t.last_sleep, Some(sleep_at));
1026 assert_eq!(t.last_micro_fixed_fire, Some(("2026-06-05".into(), 540)));
1027 assert_eq!(t.last_long_fixed_fire, Some(("2026-06-05".into(), 600)));
1028 }
1029
1030 #[tokio::test]
1031 async fn resume_when_already_running_leaves_interval_clocks_untouched() {
1032 let (_dir, sched) = test_scheduler(Settings::default());
1035 let anchor = Instant::now();
1036 {
1037 let mut t = sched.timers.lock().await;
1038 t.last_micro = anchor;
1039 t.last_long = anchor;
1040 }
1041 resume_impl(&sched).await;
1042 let t = sched.timers.lock().await;
1043 assert_eq!(t.last_micro, anchor);
1044 assert_eq!(t.last_long, anchor);
1045 }
1046
1047 #[tokio::test]
1048 async fn postpone_break_bumps_counter_and_returns_delay() {
1049 let settings = Settings {
1050 postpone_enabled: true,
1051 postpone_escalation_enabled: true,
1052 postpone_minutes: 5,
1053 postpone_escalation_step_secs: 120,
1054 postpone_max_count: 3,
1055 ..Settings::default()
1056 };
1057 let (_dir, sched) = test_scheduler(settings);
1058 let out = postpone_break_impl(&sched, BreakKind::Micro).await.unwrap();
1059 assert_eq!(out.postpone_secs, 300);
1060 let t = sched.timers.lock().await;
1061 assert_eq!(t.micro_postpone_count, 1);
1062 assert!(matches!(
1063 t.last_skipped_or_postponed,
1064 Some((BreakKind::Micro, _))
1065 ));
1066 drop(t);
1067 let out2 = postpone_break_impl(&sched, BreakKind::Micro).await.unwrap();
1069 assert_eq!(out2.postpone_secs, 420);
1070 assert_eq!(sched.timers.lock().await.micro_postpone_count, 2);
1071 }
1072
1073 #[tokio::test]
1074 async fn postpone_break_errors_when_max_reached() {
1075 let settings = Settings {
1076 postpone_enabled: true,
1077 postpone_escalation_enabled: true,
1078 postpone_minutes: 5,
1079 postpone_escalation_step_secs: 120,
1080 postpone_max_count: 2,
1081 ..Settings::default()
1082 };
1083 let (_dir, sched) = test_scheduler(settings);
1084 postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
1085 postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
1086 let err = postpone_break_impl(&sched, BreakKind::Long)
1087 .await
1088 .expect_err("third postpone should hit the cap");
1089 assert_eq!(err, "postpone exhausted");
1090 }
1091
1092 #[tokio::test]
1093 async fn postpone_break_errors_when_strict_mode_or_disabled() {
1094 let strict = Settings {
1095 strict_mode: true,
1096 postpone_enabled: true,
1097 ..Settings::default()
1098 };
1099 let (_dir, sched) = test_scheduler(strict);
1100 let err = postpone_break_impl(&sched, BreakKind::Micro)
1101 .await
1102 .expect_err("strict mode blocks postpone");
1103 assert_eq!(err, "postpone disabled");
1104
1105 let disabled = Settings {
1106 strict_mode: false,
1107 postpone_enabled: false,
1108 ..Settings::default()
1109 };
1110 let (_dir2, sched2) = test_scheduler(disabled);
1111 let err = postpone_break_impl(&sched2, BreakKind::Micro)
1112 .await
1113 .expect_err("postpone_enabled=false blocks postpone");
1114 assert_eq!(err, "postpone disabled");
1115 }
1116
1117 #[tokio::test]
1118 async fn postpone_break_blocked_when_only_that_kind_disabled() {
1119 let settings = Settings {
1122 strict_mode: false,
1123 postpone_enabled: true,
1124 micro_postpone_enabled: false,
1125 long_postpone_enabled: true,
1126 postpone_escalation_enabled: false,
1127 ..Settings::default()
1128 };
1129 let (_dir, sched) = test_scheduler(settings);
1130 let err = postpone_break_impl(&sched, BreakKind::Micro)
1131 .await
1132 .expect_err("per-kind micro postpone off blocks postpone");
1133 assert_eq!(err, "postpone disabled");
1134
1135 postpone_break_impl(&sched, BreakKind::Long)
1136 .await
1137 .expect("long postpone still allowed");
1138 }
1139
1140 #[tokio::test]
1141 async fn skip_next_break_resets_anchor_and_increments_stats() {
1142 let (_dir, sched) = test_scheduler(Settings::default());
1143 {
1145 let mut t = sched.timers.lock().await;
1146 t.last_micro = Instant::now()
1147 .checked_sub(Duration::from_secs(3_600))
1148 .unwrap_or_else(Instant::now);
1149 t.micro_postpone_count = 5;
1150 t.micro_warned = true;
1151 }
1152 skip_next_break_impl(&sched, BreakKind::Micro)
1153 .await
1154 .unwrap();
1155 let t = sched.timers.lock().await;
1156 assert_eq!(t.micro_postpone_count, 0);
1157 assert!(!t.micro_warned);
1158 assert!(t.last_micro.elapsed() < Duration::from_secs(1));
1160 assert!(matches!(
1161 t.last_skipped_or_postponed,
1162 Some((BreakKind::Micro, _))
1163 ));
1164 drop(t);
1165 assert_eq!(sched.stats.lock().await.skipped, 1);
1166 }
1167
1168 #[tokio::test]
1169 async fn skip_next_break_errors_in_strict_mode() {
1170 let strict = Settings {
1171 strict_mode: true,
1172 ..Settings::default()
1173 };
1174 let (_dir, sched) = test_scheduler(strict);
1175 let err = skip_next_break_impl(&sched, BreakKind::Micro)
1176 .await
1177 .expect_err("strict mode blocks skip");
1178 assert_eq!(err, "strict mode active");
1179 }
1180
1181 #[tokio::test]
1182 async fn skip_next_break_long_resets_both_anchors_and_counter() {
1183 let (_dir, sched) = test_scheduler(Settings::default());
1187 {
1188 let mut t = sched.timers.lock().await;
1189 t.last_long = Instant::now()
1190 .checked_sub(Duration::from_secs(3_600))
1191 .unwrap_or_else(Instant::now);
1192 t.last_micro = Instant::now()
1193 .checked_sub(Duration::from_secs(3_600))
1194 .unwrap_or_else(Instant::now);
1195 t.long_postpone_count = 4;
1196 t.long_warned = true;
1197 t.micro_warned = true;
1198 }
1199 skip_next_break_impl(&sched, BreakKind::Long).await.unwrap();
1200 let t = sched.timers.lock().await;
1201 assert_eq!(t.long_postpone_count, 0);
1202 assert!(!t.long_warned);
1203 assert!(!t.micro_warned);
1204 assert!(t.last_long.elapsed() < Duration::from_secs(1));
1205 assert!(t.last_micro.elapsed() < Duration::from_secs(1));
1206 assert!(matches!(
1207 t.last_skipped_or_postponed,
1208 Some((BreakKind::Long, _))
1209 ));
1210 }
1211
1212 #[tokio::test]
1213 async fn skip_next_break_sleep_sets_last_sleep_marker() {
1214 let (_dir, sched) = test_scheduler(Settings::default());
1215 assert!(sched.timers.lock().await.last_sleep.is_none());
1216 skip_next_break_impl(&sched, BreakKind::Sleep)
1217 .await
1218 .unwrap();
1219 let t = sched.timers.lock().await;
1220 assert!(t.last_sleep.is_some());
1221 assert!(matches!(
1222 t.last_skipped_or_postponed,
1223 Some((BreakKind::Sleep, _))
1224 ));
1225 }
1226
1227 #[tokio::test]
1228 async fn postpone_break_long_bumps_long_counter_and_resets_micro_anchor() {
1229 let settings = Settings {
1232 postpone_enabled: true,
1233 postpone_escalation_enabled: true,
1234 postpone_minutes: 5,
1235 postpone_escalation_step_secs: 120,
1236 postpone_max_count: 3,
1237 ..Settings::default()
1238 };
1239 let (_dir, sched) = test_scheduler(settings);
1240 let out = postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
1241 assert_eq!(out.postpone_secs, 300);
1242 let t = sched.timers.lock().await;
1243 assert_eq!(t.long_postpone_count, 1);
1244 assert!(!t.micro_warned);
1246 assert!(t.micro_deferred_since.is_none());
1247 assert!(matches!(
1248 t.last_skipped_or_postponed,
1249 Some((BreakKind::Long, _))
1250 ));
1251 }
1252
1253 #[tokio::test]
1254 async fn postpone_break_sleep_records_last_sleep() {
1255 let settings = Settings {
1256 postpone_enabled: true,
1257 postpone_escalation_enabled: false,
1259 postpone_minutes: 5,
1260 ..Settings::default()
1261 };
1262 let (_dir, sched) = test_scheduler(settings);
1263 assert!(sched.timers.lock().await.last_sleep.is_none());
1264 postpone_break_impl(&sched, BreakKind::Sleep).await.unwrap();
1265 let t = sched.timers.lock().await;
1266 assert!(t.last_sleep.is_some());
1267 assert!(matches!(
1268 t.last_skipped_or_postponed,
1269 Some((BreakKind::Sleep, _))
1270 ));
1271 }
1272
1273 #[tokio::test]
1274 async fn postpone_break_with_escalation_disabled_ignores_cap() {
1275 let settings = Settings {
1279 postpone_enabled: true,
1280 postpone_escalation_enabled: false,
1281 postpone_minutes: 5,
1282 postpone_escalation_step_secs: 120,
1283 postpone_max_count: 1,
1284 ..Settings::default()
1285 };
1286 let (_dir, sched) = test_scheduler(settings);
1287 for _ in 0..3 {
1288 let out = postpone_break_impl(&sched, BreakKind::Micro)
1289 .await
1290 .expect("escalation off uncaps postpone");
1291 assert_eq!(out.postpone_secs, 300, "no escalation = constant 5 min");
1292 }
1293 assert_eq!(sched.timers.lock().await.micro_postpone_count, 3);
1294 }
1295
1296 async fn wait_for_log_substring(path: &std::path::Path, marker: &str) {
1300 let deadline = Instant::now() + Duration::from_secs(2);
1301 while Instant::now() < deadline {
1302 if let Ok(contents) = std::fs::read_to_string(path) {
1303 if contents.contains(marker) {
1304 return;
1305 }
1306 }
1307 tokio::time::sleep(Duration::from_millis(25)).await;
1308 }
1309 }
1310
1311 #[tokio::test]
1312 async fn pause_when_already_paused_does_not_re_fire_hooks() {
1313 let (_dir, sched) = test_scheduler(Settings::default());
1316 pause_impl(&sched, Some(60)).await;
1317 wait_for_log_substring(&sched.events_path, "\"type\":\"pause_start\"").await;
1318 pause_impl(&sched, Some(900)).await;
1319 tokio::time::sleep(Duration::from_millis(150)).await;
1322 let log = std::fs::read_to_string(&sched.events_path).unwrap_or_default();
1323 let count = log.matches("\"type\":\"pause_start\"").count();
1324 assert_eq!(
1325 count, 1,
1326 "pause_start only fires on the running→paused edge, got log:\n{log}",
1327 );
1328 }
1329
1330 #[tokio::test]
1331 async fn resume_when_already_running_is_a_noop() {
1332 let (_dir, sched) = test_scheduler(Settings::default());
1335 resume_impl(&sched).await;
1336 tokio::time::sleep(Duration::from_millis(150)).await;
1338 let log = std::fs::read_to_string(&sched.events_path).unwrap_or_default();
1339 assert!(
1340 !log.contains("\"type\":\"pause_end\""),
1341 "resume on a Running scheduler must not log pause_end, got log:\n{log}",
1342 );
1343 assert!(matches!(
1345 *sched.pause_state.lock().await,
1346 PauseState::Running
1347 ));
1348 }
1349
1350 #[tokio::test]
1351 async fn compute_postpone_state_sleep_is_uncapped_even_with_escalation_on() {
1352 let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
1356 let timers = Arc::new(Mutex::new(timers_with_postpone(0, 0)));
1357 let sleep = compute_postpone_state(&settings, &timers, BreakKind::Sleep).await;
1358 assert_eq!(sleep.max, u32::MAX);
1359 assert_eq!(sleep.count, 0);
1360 assert_eq!(sleep.remaining, u32::MAX);
1361 }
1362
1363 #[tokio::test]
1364 async fn end_break_completed_resets_postpone_counter_and_clears_last() {
1365 let (_dir, sched) = test_scheduler(Settings::default());
1371 {
1372 let mut t = sched.timers.lock().await;
1373 t.active_break = Some(BreakKind::Micro);
1374 t.micro_postpone_count = 2;
1375 t.last_skipped_or_postponed = Some((BreakKind::Micro, Instant::now()));
1376 }
1377 let active_kind = {
1380 let mut t = sched.timers.lock().await;
1381 t.active_break.take()
1382 };
1383 assert_eq!(active_kind, Some(BreakKind::Micro));
1384 let mut t = sched.timers.lock().await;
1385 reset_postpone_counter(&mut t, BreakKind::Micro);
1386 let cleared = clear_last_break(&mut t);
1387 assert!(
1388 cleared,
1389 "clear_last_break returns true when slot was populated"
1390 );
1391 assert_eq!(t.micro_postpone_count, 0);
1392 assert!(t.last_skipped_or_postponed.is_none());
1393 }
1394}
1395
1396#[cfg(all(test, not(target_os = "windows")))]
1412mod rig_smoke_tests {
1413 use super::*;
1414 use crate::test_support::mock_app_with_scheduler;
1415 use std::sync::atomic::{AtomicUsize, Ordering};
1416 use std::sync::Arc;
1417 use tauri::{Listener, Manager};
1418
1419 #[tokio::test]
1420 async fn pause_command_via_rig_emits_pause_changed() {
1421 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1426
1427 let fired = Arc::new(AtomicUsize::new(0));
1432 {
1433 let fired = fired.clone();
1434 app.listen("pause:changed", move |event| {
1435 let payload: bool =
1436 serde_json::from_str(event.payload()).expect("pause:changed payload is a bool");
1437 if payload {
1438 fired.fetch_add(1, Ordering::SeqCst);
1439 }
1440 });
1441 }
1442
1443 let state = app.state::<Scheduler>();
1444 pause(app.handle().clone(), state, Some(60))
1445 .await
1446 .expect("pause command succeeds");
1447
1448 assert_eq!(
1449 fired.load(Ordering::SeqCst),
1450 1,
1451 "pause:changed(true) fired once"
1452 );
1453 assert!(!matches!(
1455 *sched.pause_state.lock().await,
1456 PauseState::Running
1457 ));
1458 }
1459
1460 #[tokio::test]
1461 async fn resume_command_via_rig_emits_pause_changed_false() {
1462 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1463 pause_impl(&sched, Some(60)).await;
1465
1466 let fired = Arc::new(AtomicUsize::new(0));
1467 {
1468 let fired = fired.clone();
1469 app.listen("pause:changed", move |event| {
1470 let payload: bool =
1471 serde_json::from_str(event.payload()).expect("pause:changed payload is a bool");
1472 if !payload {
1473 fired.fetch_add(1, Ordering::SeqCst);
1474 }
1475 });
1476 }
1477
1478 let state = app.state::<Scheduler>();
1479 resume(app.handle().clone(), state)
1480 .await
1481 .expect("resume command succeeds");
1482
1483 assert_eq!(
1484 fired.load(Ordering::SeqCst),
1485 1,
1486 "pause:changed(false) fired once"
1487 );
1488 assert!(matches!(
1489 *sched.pause_state.lock().await,
1490 PauseState::Running
1491 ));
1492 }
1493
1494 #[tokio::test]
1495 async fn end_break_command_via_rig_emits_break_end_and_increments_taken() {
1496 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1497
1498 let fired = Arc::new(AtomicUsize::new(0));
1499 {
1500 let fired = fired.clone();
1501 app.listen("break:end", move |_event| {
1502 fired.fetch_add(1, Ordering::SeqCst);
1503 });
1504 }
1505
1506 let state = app.state::<Scheduler>();
1507 end_break(app.handle().clone(), state, Some("completed".to_string()))
1508 .await
1509 .expect("end_break succeeds");
1510
1511 assert_eq!(fired.load(Ordering::SeqCst), 1, "break:end fired once");
1512 assert_eq!(sched.stats.lock().await.taken, 1);
1513 }
1514
1515 #[tokio::test]
1516 async fn postpone_break_command_via_rig_emits_break_end_and_last_break_changed() {
1517 let settings = Settings {
1518 postpone_enabled: true,
1519 postpone_minutes: 5,
1520 ..Settings::default()
1521 };
1522 let (_dir, app, sched) = mock_app_with_scheduler(settings);
1523
1524 let break_end = Arc::new(AtomicUsize::new(0));
1525 let last_break_changed = Arc::new(std::sync::Mutex::new(None::<serde_json::Value>));
1526 {
1527 let break_end = break_end.clone();
1528 app.listen("break:end", move |_event| {
1529 break_end.fetch_add(1, Ordering::SeqCst);
1530 });
1531 }
1532 {
1533 let captured = last_break_changed.clone();
1534 app.listen("last_break:changed", move |event| {
1535 let v: serde_json::Value = serde_json::from_str(event.payload())
1536 .expect("last_break:changed payload is JSON");
1537 *captured.lock().unwrap() = Some(v);
1538 });
1539 }
1540
1541 let state = app.state::<Scheduler>();
1542 postpone_break(app.handle().clone(), state, BreakKind::Micro)
1543 .await
1544 .expect("postpone_break succeeds");
1545
1546 assert_eq!(break_end.load(Ordering::SeqCst), 1, "break:end fired once");
1547 let payload = last_break_changed
1548 .lock()
1549 .unwrap()
1550 .clone()
1551 .expect("last_break:changed was emitted");
1552 assert_eq!(
1553 payload["kind"], "micro",
1554 "last_break:changed carries the postponed kind"
1555 );
1556 assert_eq!(sched.timers.lock().await.micro_postpone_count, 1);
1558 }
1559
1560 #[tokio::test]
1561 async fn postpone_break_command_propagates_impl_error() {
1562 let strict = Settings {
1565 strict_mode: true,
1566 postpone_enabled: true,
1567 ..Settings::default()
1568 };
1569 let (_dir, app, _sched) = mock_app_with_scheduler(strict);
1570 let state = app.state::<Scheduler>();
1571 let err = postpone_break(app.handle().clone(), state, BreakKind::Micro)
1572 .await
1573 .expect_err("strict mode blocks postpone");
1574 assert_eq!(err, "postpone disabled");
1575 }
1576
1577 #[tokio::test]
1578 async fn skip_next_break_command_via_rig_emits_stats_changed_and_last_break_changed() {
1579 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1580
1581 let stats_changed = Arc::new(AtomicUsize::new(0));
1582 let last_break_changed = Arc::new(AtomicUsize::new(0));
1583 {
1584 let stats_changed = stats_changed.clone();
1585 app.listen("stats:changed", move |_event| {
1586 stats_changed.fetch_add(1, Ordering::SeqCst);
1587 });
1588 }
1589 {
1590 let last_break_changed = last_break_changed.clone();
1591 app.listen("last_break:changed", move |_event| {
1592 last_break_changed.fetch_add(1, Ordering::SeqCst);
1593 });
1594 }
1595
1596 let state = app.state::<Scheduler>();
1597 skip_next_break(app.handle().clone(), state, BreakKind::Micro)
1598 .await
1599 .expect("skip_next_break succeeds");
1600
1601 assert_eq!(
1602 stats_changed.load(Ordering::SeqCst),
1603 1,
1604 "stats:changed fired once"
1605 );
1606 assert_eq!(
1607 last_break_changed.load(Ordering::SeqCst),
1608 1,
1609 "last_break:changed fired once"
1610 );
1611 assert_eq!(sched.stats.lock().await.skipped, 1);
1612 }
1613
1614 #[tokio::test]
1615 async fn skip_next_break_command_propagates_strict_mode_error() {
1616 let strict = Settings {
1617 strict_mode: true,
1618 ..Settings::default()
1619 };
1620 let (_dir, app, _sched) = mock_app_with_scheduler(strict);
1621 let state = app.state::<Scheduler>();
1622 let err = skip_next_break(app.handle().clone(), state, BreakKind::Micro)
1623 .await
1624 .expect_err("strict mode blocks skip");
1625 assert_eq!(err, "strict mode active");
1626 }
1627
1628 #[tokio::test]
1629 async fn resume_last_break_command_errors_when_nothing_to_resume() {
1630 let (_dir, app, _sched) = mock_app_with_scheduler(Settings::default());
1634 let state = app.state::<Scheduler>();
1635 let err = resume_last_break(app.handle().clone(), state)
1636 .await
1637 .expect_err("empty slot blocks resume");
1638 assert_eq!(err, "no break to resume");
1639 }
1640
1641 #[tokio::test]
1642 async fn end_break_command_classifies_dismissed_and_emits_stats_changed() {
1643 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1650
1651 let captured: Arc<std::sync::Mutex<Option<serde_json::Value>>> =
1652 Arc::new(std::sync::Mutex::new(None));
1653 {
1654 let captured = captured.clone();
1655 app.listen("stats:changed", move |event| {
1656 let v: serde_json::Value =
1657 serde_json::from_str(event.payload()).expect("stats:changed payload is JSON");
1658 *captured.lock().unwrap() = Some(v);
1659 });
1660 }
1661
1662 let state = app.state::<Scheduler>();
1663 end_break(app.handle().clone(), state, Some("dismissed".to_string()))
1664 .await
1665 .expect("end_break(dismissed) succeeds");
1666
1667 let stats = sched.stats.lock().await;
1668 assert_eq!(stats.skipped, 1);
1669 assert_eq!(stats.taken, 0);
1670 let emitted = captured
1671 .lock()
1672 .unwrap()
1673 .clone()
1674 .expect("stats:changed was emitted");
1675 assert_eq!(
1676 emitted["skipped"], 1,
1677 "renderer sees the post-dismiss skipped",
1678 );
1679 assert_eq!(emitted["taken"], 0);
1680 }
1681
1682 #[tokio::test]
1683 #[allow(clippy::field_reassign_with_default)]
1684 async fn trigger_break_from_cli_resolves_fire_fields_and_delivers() {
1685 use super::super::super::settings::BreakMode;
1691 let mut settings = Settings::default();
1692 settings.long_break_mode = BreakMode::Notification;
1693 let (_dir, app, sched) = mock_app_with_scheduler(settings);
1694 trigger_break_from_cli(app.handle(), &sched, BreakKind::Long, 42).await;
1695 assert!(sched.current_break.lock().unwrap().is_none());
1697 }
1698
1699 #[tokio::test]
1700 #[allow(clippy::field_reassign_with_default)]
1701 async fn start_long_break_now_reanchors_both_interval_clocks() {
1702 use super::super::super::settings::BreakMode;
1708 let mut settings = Settings::default();
1709 settings.long_break_mode = BreakMode::Notification;
1710 settings.long_interval_secs = 1_800;
1711 settings.micro_interval_secs = 1_200;
1712 let (_dir, app, sched) = mock_app_with_scheduler(settings);
1713
1714 let stale = Instant::now();
1718 {
1719 let mut t = sched.timers.lock().await;
1720 t.last_micro = stale;
1721 t.last_long = stale;
1722 t.long_warned = true;
1723 t.micro_warned = true;
1724 }
1725
1726 start_long_break_now_impl(app.handle(), &sched).await;
1727
1728 let t = sched.timers.lock().await;
1729 assert!(
1730 t.last_long > stale,
1731 "long anchor must move forward to the fire instant"
1732 );
1733 assert!(
1734 t.last_micro > stale,
1735 "a long break swallows the pending micro, so its clock re-anchors too"
1736 );
1737 assert!(!t.long_warned, "the long warn flag resets on fire");
1738 assert!(!t.micro_warned, "the micro warn flag resets on fire");
1739 assert!(
1740 !crate::scheduler::timers::interval_break_due(
1741 true,
1742 true,
1743 t.last_long,
1744 1_800,
1745 false,
1746 t.last_long
1747 ),
1748 "no long break may be due the instant after taking one"
1749 );
1750 }
1751
1752 #[tokio::test]
1753 #[allow(clippy::field_reassign_with_default)]
1754 async fn start_long_break_now_command_via_rig_fires_and_reanchors() {
1755 use super::super::super::settings::BreakMode;
1760 let mut settings = Settings::default();
1761 settings.long_break_mode = BreakMode::Notification;
1762 let (_dir, app, sched) = mock_app_with_scheduler(settings);
1763 let stale = Instant::now();
1764 {
1765 let mut t = sched.timers.lock().await;
1766 t.last_long = stale;
1767 }
1768
1769 start_long_break_now(app.handle().clone(), app.state::<Scheduler>())
1770 .await
1771 .expect("start_long_break_now command succeeds");
1772
1773 assert!(
1774 sched.timers.lock().await.last_long > stale,
1775 "the wrapper drove the fire path and re-anchored the long clock"
1776 );
1777 }
1778
1779 #[test]
1780 #[allow(clippy::field_reassign_with_default)]
1781 fn resume_break_event_resolves_per_kind_postpone_and_skip() {
1782 let mut s = Settings::default();
1788 s.postpone_enabled = true;
1789 s.micro_postpone_enabled = true;
1790 s.micro_skip_enabled = false;
1791 s.long_postpone_enabled = false;
1792 s.long_skip_enabled = true;
1793
1794 let micro = resume_break_event(BreakKind::Micro, &s, 0.0);
1795 assert!(micro.postpone_available, "micro postpone enabled per-kind");
1796 assert!(!micro.skip_available, "micro skip disabled per-kind");
1797
1798 let long = resume_break_event(BreakKind::Long, &s, 0.0);
1799 assert!(!long.postpone_available, "long postpone disabled per-kind");
1800 assert!(long.skip_available, "long skip enabled per-kind");
1801 }
1802
1803 #[tokio::test]
1804 async fn resume_last_break_impl_errors_without_a_stashed_break() {
1805 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1806 let err = resume_last_break_impl(app.handle(), &sched)
1807 .await
1808 .expect_err("nothing to resume");
1809 assert_eq!(err, "no break to resume");
1810 }
1811
1812 #[tokio::test]
1813 async fn abort_stranded_break_clears_break_and_records_no_stats() {
1814 let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1818 let _overlay = tauri::WebviewWindowBuilder::new(
1820 &app,
1821 "overlay-0",
1822 tauri::WebviewUrl::App("index.html".into()),
1823 )
1824 .visible(false)
1825 .build()
1826 .expect("mock overlay window builds");
1827 *crate::scheduler::lock_current_break(&sched.current_break) = Some(BreakEvent {
1828 kind: BreakKind::Micro,
1829 duration_secs: 30,
1830 enforceable: false,
1831 manual_finish: false,
1832 postpone_available: true,
1833 skip_available: true,
1834 hints: vec![],
1835 hint_rotate_seconds: 0,
1836 health_intensity: 0.0,
1837 routine_steps: vec![],
1838 routine_pacing: None,
1839 routine_max_step_secs: None,
1840 routine_breath: None,
1841 chore_prompt: None,
1842 });
1843
1844 let ended = Arc::new(AtomicUsize::new(0));
1845 {
1846 let ended = ended.clone();
1847 app.listen("break:end", move |_| {
1848 ended.fetch_add(1, Ordering::SeqCst);
1849 });
1850 }
1851 let stats_before = sched.stats.lock().await.clone();
1852
1853 crate::scheduler::overlay::abort_stranded_break(app.handle(), &sched.current_break);
1854
1855 assert!(
1856 crate::scheduler::lock_current_break(&sched.current_break).is_none(),
1857 "stranded teardown clears the current break",
1858 );
1859 let stats_after = sched.stats.lock().await.clone();
1860 assert_eq!(
1861 (
1862 stats_after.taken,
1863 stats_after.skipped,
1864 stats_after.postponed
1865 ),
1866 (
1867 stats_before.taken,
1868 stats_before.skipped,
1869 stats_before.postponed
1870 ),
1871 "an invisible break records no taken/dismissed/postponed",
1872 );
1873 assert_eq!(ended.load(Ordering::SeqCst), 1, "break:end is emitted");
1874 }
1875
1876 #[test]
1877 fn notify_overlay_rendered_acks_the_render_watchdog() {
1878 let epoch = OVERLAY_ACK.arm();
1882 notify_overlay_rendered();
1883 assert!(
1884 !OVERLAY_ACK.is_stranded(epoch),
1885 "an acked break is not stranded",
1886 );
1887 }
1888}