Skip to main content

entracte_lib/scheduler/commands/
breaks.rs

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/// Pause the scheduler. `duration_secs = None` pauses indefinitely;
22/// `Some(n)` pauses for `n` seconds. Fires `pause_start` hooks and
23/// emits the `pause:changed` event. Idempotent — a pause-while-paused
24/// updates the deadline but doesn't re-fire hooks.
25#[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/// Resume the scheduler from any pause state. Fires `pause_end` hooks
37/// and emits `pause:changed`. No-op if already running.
38#[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
48/// Pause-state mutation core: updates the in-memory state, persists,
49/// logs the event, and fires `pause_start` hooks on a true running→paused
50/// edge. AppHandle-free so unit tests can drive it.
51pub 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
75/// Resume-state mutation core: clears the pause, persists, logs, and
76/// fires `pause_end` hooks on a true paused→running edge.
77pub 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/// Current pause status for the renderer. While paused with a
99/// deadline, `remaining_secs` is the live countdown; while paused
100/// indefinitely it's `None`.
101#[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
124/// Mirrors the enforceability rule used by the scheduler's normal
125/// break-firing paths in `run_loop.rs`: micro/long obey their own
126/// `*_enforceable` flag OR `strict_mode`, while sleep is always
127/// enforceable.
128pub(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
133/// The `(duration_secs, enforceable, manual_finish, hints)` a break of
134/// `kind` fires with. Single source for the scheduled-fire, resume, and
135/// CLI-trigger paths, which each used to repeat this per-kind `match`.
136/// Enforceability goes through [`test_break_enforceable`] so every path
137/// shares one rule; duration / manual-finish / hints come from the
138/// matching `Settings` accessors.
139pub(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
149/// Fire a one-off break of the given kind right now. Shared by the
150/// renderer-facing `trigger_test_break` and the CLI's `trigger`
151/// command. Bypasses suppression checks (the user asked explicitly).
152pub 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    // `duration_secs` here is the caller-supplied one-off length, so discard
160    // the scheduled duration from `fire_fields` and keep the rest.
161    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/// Renderer hook to fire a break immediately — used by the "Test now"
202/// buttons on the Schedule tab.
203#[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/// Called by the break overlay the moment it has rendered a break — any
215/// successful IPC from the overlay proves its webview is alive and executing.
216/// Acks the render-readiness watchdog (#196 / #226) so a healthy break is
217/// never torn down. No scheduler state: just the process-wide ack.
218#[tauri::command]
219pub fn notify_overlay_rendered() {
220    OVERLAY_ACK.ack();
221}
222
223/// Conclude the currently-active break. `reason` distinguishes
224/// `"completed"` (taken in full), `"dismissed"` (user closed it
225/// early), and `"postponed"` (countdown wasn't honoured). Updates the
226/// session counters, fires `break_end` hooks, hides every overlay
227/// window, and emits `break:end`.
228#[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    // Defensively disarm the render watchdog (#196/#226): a normal end means
283    // the overlay was up, but raising the ack here guarantees a late watchdog
284    // can never tear down an already-ended break.
285    OVERLAY_ACK.ack();
286    // Resume any media `fire_break` paused for this break (#77). No-op
287    // unless something was paused.
288    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/// Push the active break out by the configured postpone interval
297/// (with optional escalation by previous postpone count). Errors when
298/// `strict_mode` / `postpone_enabled = false` block postpone or when
299/// the per-break postpone cap is reached. Side-effects: bumps the
300/// per-kind postpone counter, fires `break_postponed` hooks, hides
301/// overlays.
302#[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/// Returned by `postpone_break_impl` so test callers can see what the
317/// command would have emitted. The shim discards this in production.
318#[derive(Debug, Clone, Copy)]
319pub struct PostponeOutcome {
320    #[allow(dead_code)]
321    pub postpone_secs: u64,
322}
323
324/// Postpone-state mutation core: validates the request, updates timers,
325/// bumps counters, logs, fires hooks. AppHandle-free; the calling
326/// `#[tauri::command]` wrapper handles overlay hides + IPC emits.
327pub 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
407/// Reset the next-break timer for `kind` so the user "skips" the
408/// upcoming break. Shared by the renderer command and the CLI's
409/// `skip` subcommand. Errors when `strict_mode` is on.
410pub 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
422/// Skip-next mutation core. Resets the per-kind interval anchor to
423/// `Instant::now()`, clears warn/deferred flags, zeroes the postpone
424/// counter, bumps the session skip total, logs, and fires hooks.
425/// AppHandle-free; the wrapper handles IPC emits.
426pub 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/// Renderer-facing skip. Thin wrapper over `skip_next_from_cli`.
470#[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/// Per-kind postpone budget snapshot for the overlay button label
480/// (e.g. "Postpone (2 of 3)").
481#[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
489/// Lock-then-snapshot helper: settings first (cloned and released),
490/// then timers (read and released). Never holds two scheduler mutex
491/// guards across an `.await`, so two concurrent callers can't deadlock
492/// even if the rest of the codebase locks them in the opposite order.
493async 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    // Drop each guard before acquiring the next so we never hold
499    // `settings` across `timers.lock().await`. Two concurrent callers
500    // taking the locks in opposite orders would otherwise be a deadlock
501    // hazard. Reading them sequentially is fine: the postpone state is
502    // a renderer convenience; tiny window between reads can't cross a
503    // user-visible boundary (postpone count is bumped from the same
504    // task that fires the overlay button).
505    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/// The most recently skipped or postponed break — drives the tray's
525/// "Resume last skipped break" menu item.
526#[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
536/// Re-fire the most recently skipped/postponed break with the current
537/// profile's full settings (duration, hints, enforceability). Shared
538/// by the renderer command and the tray menu handler. Errors with
539/// `"no break to resume"` when the slot is empty.
540fn 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        // Resuming re-fires a break the user already skipped/postponed; the
576        // chore nudge (and its rotation) was resolved on the original fire,
577        // so a resume stays chore-free rather than advancing it again.
578        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/// Renderer-facing resume. Thin wrapper over `resume_last_break_impl`.
637#[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
645/// Fire a long break right now and restart the rotation, exactly as if the
646/// scheduled long break had come due (#258). Lets a user take their long
647/// break early — a chore timer went off, the washing machine finished —
648/// without waiting for it and without it doubling up later:
649/// `fire_scheduled_break` re-anchors *both* interval clocks (a long break
650/// swallows the pending micro). Bypasses suppression, matching the other
651/// explicit manual triggers. Shared by the renderer command and the tray.
652pub 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/// Renderer-facing "take a long break now". Thin wrapper over
658/// `start_long_break_now_impl`.
659#[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); // micro not enforceable, no strict mode
792        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    // Fix #3: `get_postpone_state` used to hold the `settings` guard
831    // across `timers.lock().await`. Two concurrent callers couldn't
832    // *actually* deadlock here (every other path takes settings first),
833    // but the doc convention says "no nested holds across await" — so
834    // the helper now drops settings first. These tests confirm both
835    // (a) no deadlock under concurrent calls and (b) consistent
836    // postpone-state values regardless of interleave.
837    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        // Bound the test so a real deadlock fails loudly instead of
863        // hanging the suite.
864        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            // Either kind's snapshot must be internally consistent.
870            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        // With escalation disabled the cap drops to u32::MAX.
890        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        // Sleep is always uncapped because sleep prompts don't escalate.
896        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        // Persistence: the pause file on disk should report paused.
914        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        // #134: paused for an hour with stale interval anchors. On resume
948        // both clocks must re-anchor to ~now, so no interval is overdue
949        // (the tray shows the full period, not 0:00) and the next due time
950        // is `resume + interval`.
951        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        // `stale` is a genuine past sample (never `now() - offset`, which
958        // can underflow the monotonic clock on a fresh Windows runner).
959        // It stands in for an anchor left behind by a long pause: with a
960        // huge interval here, it would be "overdue" without the re-anchor.
961        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        // A no-op resume (already Running) must not re-anchor, otherwise a
1033        // stray resume call would reset a user's mid-interval progress.
1034        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        // Second postpone escalates per `postpone_escalation_step_secs`.
1068        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        // Global master on, long postpone on, micro postpone off: the
1120        // micro request is rejected while the long path stays open.
1121        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        // Pre-set an older anchor so we can verify it was bumped.
1144        {
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        // The anchor should be ~now (within 1s).
1159        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        // Long-break skip resets micro state too: a long break "swallows"
1184        // the upcoming micro, so the user shouldn't be hit with a micro
1185        // a moment after skipping a long.
1186        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        // Long-postpone bumps long's counter and also pushes back the
1230        // micro anchor so a micro doesn't fire inside the postpone window.
1231        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        // Micro state must be pushed back too, not just long's.
1245        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            // Escalation off so sleep doesn't hit the cap check.
1258            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        // postpone_escalation_enabled=false bypasses the per-kind cap
1276        // even when the counter is already past it — escalation off means
1277        // each postpone is just the base duration with no limit.
1278        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    /// Poll the events.jsonl file until it contains the given marker
1297    /// substring or the timeout elapses. The logger writes on a
1298    /// background thread, so a fixed sleep is racy on loaded CI runners.
1299    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        // pause_impl logs pause_start only on the running→paused edge.
1314        // A second pause-while-paused must not produce a second log entry.
1315        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        // Drain the logger queue: a second event would land within the
1320        // same window the first did, so polling a touch longer is enough.
1321        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        // resume_impl on a Running scheduler is a no-op — it must not
1333        // log a pause_end event when there was no pause to end.
1334        let (_dir, sched) = test_scheduler(Settings::default());
1335        resume_impl(&sched).await;
1336        // Wait long enough that a real pause_end log would have flushed.
1337        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        // State stays Running across the no-op.
1344        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        // Sleep breaks never escalate; their postpone slot in
1353        // `compute_postpone_state` should always report `max = u32::MAX`
1354        // regardless of the `postpone_escalation_enabled` setting.
1355        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        // The end_break command itself needs an AppHandle, but the
1366        // state mutations it triggers in the scheduler are observable
1367        // without one: stash an active break, then re-implement the
1368        // "completed" tail (stats + counter reset + clear_last) and
1369        // confirm the helpers leave the scheduler in the expected shape.
1370        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        // Drive the same path end_break's "completed" branch takes:
1378        // active_kind.take() + reset_postpone_counter + clear_last_break.
1379        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// =====================================================================
1397// Integration-test rig (closes #10).
1398//
1399// The tests above call the `*_impl` cores directly so they don't need
1400// an `AppHandle`. These tests exercise the full `#[tauri::command]`
1401// wrappers via `test_support::mock_app_with_scheduler`. Every command
1402// in this crate is generic over `R: Runtime`, so the rig isn't limited
1403// to the wrappers below — add more rig tests as needed for any
1404// command that emits or touches `AppHandle`.
1405//
1406// Not compiled on Windows — `tauri = { features = ["test"] }` pulls
1407// in `wry`/WebView2 bindings whose `WebView2Loader.dll` entry-point
1408// doesn't match the GitHub Actions Windows image. macOS + Ubuntu CI
1409// still exercises these tests.
1410// =====================================================================
1411#[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        // Scope: prove the `#[tauri::command]` wrapper threads the
1422        // AppHandle through and the `pause:changed` emit fires. Disk
1423        // persistence is `pause_impl`'s job and is covered by the
1424        // impl-level test `pause_some_secs_transitions_running_to_timed_pause`.
1425        let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1426
1427        // Wire up a listener BEFORE invoking the command so we don't
1428        // miss the emit. Parse with `.expect` so a payload-shape change
1429        // fails loudly rather than silently dropping the event and
1430        // letting the count assertion misreport the root cause.
1431        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        // Wrapper-level sanity: the impl actually ran, not just the emit.
1454        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        // Start paused so resume has work to do.
1464        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        // …and the impl side-effect: postpone counter is now 1.
1557        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        // strict_mode blocks postpone — the wrapper must propagate the
1563        // Err, not swallow it. No emits happen on the error path.
1564        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        // `resume_last_break_impl` errors with "no break to resume" when
1631        // the `last_skipped_or_postponed` slot is empty. The wrapper
1632        // must surface that string verbatim.
1633        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        // Same rig path, different reason — proves the wrapper threads
1644        // the `reason` arg through to the impl AND that the
1645        // `stats:changed` emit carries the post-mutation snapshot.
1646        // `BreakStats` is `Serialize`-only in prod, so parse the payload
1647        // as a generic JSON value to avoid adding `Deserialize` solely
1648        // for tests.
1649        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        // Exercises the CLI/test-break entry: it pulls per-kind fields from
1686        // `fire_fields` and delivers a one-off break. Notification mode keeps
1687        // delivery windowless so the mock runtime doesn't try to open an
1688        // overlay; the `fire_fields` resolution (the line under test) runs
1689        // before delivery regardless of mode.
1690        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        // Notification delivery does not stash an overlay break.
1696        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        // #258: "take a long break now" fires a real long break AND restarts
1703        // the rotation, so a break already overdue on the clock is no longer
1704        // due the instant after — no doubled-up break later. Notification mode
1705        // keeps delivery windowless (the mock runtime can't open an overlay);
1706        // the re-anchor is `fire_scheduled_break`'s job regardless of channel.
1707        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        // A genuine past sample (never `now() - offset`, which can underflow
1715        // the monotonic clock on a fresh runner) standing in for stale anchors
1716        // left overdue by a long stretch of work.
1717        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        // Covers the `#[tauri::command]` wrapper (State/AppHandle threading),
1756        // separate from the impl-level test above. Notification mode keeps
1757        // delivery windowless under the mock runtime; a moved long anchor
1758        // proves the wrapper reached the impl and the fire path ran.
1759        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        // The resume fire site builds its BreakEvent via this helper, which
1783        // resolves postpone/skip per kind. Driving `resume_last_break_impl`
1784        // end-to-end isn't possible under the mock runtime (it calls
1785        // `fire_break`, which enumerates monitors), so the resolution lives
1786        // here where it's directly testable.
1787        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        // The render watchdog's teardown (#196/#226): a break whose overlay
1815        // never rendered is cleared and a `break:end` emitted, but NO stats
1816        // are recorded — an invisible break was never taken or dismissed.
1817        let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
1818        // A live "overlay-" window so the teardown's hide path actually runs.
1819        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        // The overlay's readiness ping disarms the watchdog so a healthy break
1879        // is never torn down. Asserts only the post-ack state, which holds
1880        // regardless of any concurrent test arming a newer epoch.
1881        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}