|
1 | 1 | use tracing::info; |
2 | 2 |
|
3 | | -use super::effect::Effect; |
4 | | -use crate::services::ServiceContext; |
| 3 | +use super::effect::{Effect, SoundType, TrayUpdate}; |
| 4 | +use super::state::TimerState; |
| 5 | +use crate::models::statistics::{CycleEventDraft, RestSessionDraft}; |
| 6 | +use crate::models::types::StatePayload; |
5 | 7 |
|
6 | | -/// Execute timer effects by dispatching to concrete services. |
7 | | -pub(crate) fn execute_effect(app: Option<&ServiceContext>, effect: &Effect) { |
8 | | - let Some(app) = app else { |
| 8 | +/// Sink that executes side effects emitted by the pure timer core. |
| 9 | +/// |
| 10 | +/// Production wires this to `ServiceContext`, which dispatches to the real |
| 11 | +/// Tauri-backed services (window / sound / tray / stat). Tests use a recording |
| 12 | +/// fake (`RecordingSink` in `#[cfg(test)]`) to assert effect-to-call mapping |
| 13 | +/// without needing an `AppHandle`. |
| 14 | +/// |
| 15 | +/// One method per `Effect` variant on purpose: a single dispatch `fn(&Effect)` |
| 16 | +/// would push the match into every fake, which CLAUDE.md forbids ("flag-driven |
| 17 | +/// branching"). Granular methods also make fakes record structured payloads |
| 18 | +/// rather than stringified `Debug`. |
| 19 | +pub(crate) trait EffectSink: Send + Sync { |
| 20 | + fn emit_state_changed(&self, payload: &StatePayload); |
| 21 | + fn show_tip_windows(&self); |
| 22 | + fn hide_tip_windows(&self); |
| 23 | + fn play_sound(&self, sound: SoundType); |
| 24 | + fn update_tray_tooltip(&self, tooltip: super::effect::TrayTooltip); |
| 25 | + fn update_tray_state_icon(&self, state: TimerState); |
| 26 | + fn reset_work_timer(&self, duration: std::time::Duration); |
| 27 | + fn record_rest_session(&self, session: &RestSessionDraft); |
| 28 | + fn record_cycle_event(&self, event: &CycleEventDraft); |
| 29 | +} |
| 30 | + |
| 31 | +/// Dispatch a single timer `Effect` to the supplied sink. |
| 32 | +/// |
| 33 | +/// `sink: Option<&dyn EffectSink>` lets the timer service run without a |
| 34 | +/// runtime context (early boot, tests using `TimerService` directly) — in that |
| 35 | +/// case the effect is logged but not executed, matching the pre-refactor |
| 36 | +/// `STUB effect:` behavior. |
| 37 | +pub(crate) fn execute_effect(sink: Option<&dyn EffectSink>, effect: &Effect) { |
| 38 | + let Some(sink) = sink else { |
9 | 39 | info!("STUB effect: {effect:?}"); |
10 | 40 | return; |
11 | 41 | }; |
12 | 42 |
|
13 | | - app.execute_timer_effect(effect); |
| 43 | + match effect { |
| 44 | + Effect::EmitStateChanged(payload) => sink.emit_state_changed(payload), |
| 45 | + Effect::ShowTipWindows => sink.show_tip_windows(), |
| 46 | + Effect::HideTipWindows => sink.hide_tip_windows(), |
| 47 | + Effect::PlaySound(sound) => sink.play_sound(*sound), |
| 48 | + Effect::UpdateTray(update) => match update { |
| 49 | + TrayUpdate::Tooltip(tooltip) => sink.update_tray_tooltip(*tooltip), |
| 50 | + TrayUpdate::StateIcon(state) => sink.update_tray_state_icon(*state), |
| 51 | + }, |
| 52 | + Effect::ResetWorkTimer(duration) => sink.reset_work_timer(*duration), |
| 53 | + Effect::RecordRestSession(session) => sink.record_rest_session(session), |
| 54 | + Effect::RecordCycleEvent(event) => sink.record_cycle_event(event), |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +#[cfg(test)] |
| 59 | +mod tests { |
| 60 | + use std::sync::Mutex; |
| 61 | + use std::time::Duration; |
| 62 | + |
| 63 | + use chrono::Utc; |
| 64 | + |
| 65 | + use super::*; |
| 66 | + use crate::models::config::TimerMode; |
| 67 | + use crate::models::statistics::CycleOutcome; |
| 68 | + use crate::services::timer::effect::{PomodoroProgress, TrayTooltip}; |
| 69 | + |
| 70 | + /// Recorded effect-call for `RecordingSink` assertions. One variant per |
| 71 | + /// `EffectSink` method so test failures point at the right boundary. |
| 72 | + #[derive(Debug, Clone, PartialEq, Eq)] |
| 73 | + enum Call { |
| 74 | + EmitStateChanged(String, u32), |
| 75 | + ShowTipWindows, |
| 76 | + HideTipWindows, |
| 77 | + PlaySound(SoundType), |
| 78 | + UpdateTrayTooltip(TrayTooltip), |
| 79 | + UpdateTrayStateIcon(TimerState), |
| 80 | + ResetWorkTimer(Duration), |
| 81 | + RecordRestSession(u32), |
| 82 | + RecordCycleEvent(CycleOutcome), |
| 83 | + } |
| 84 | + |
| 85 | + #[derive(Default)] |
| 86 | + struct RecordingSink { |
| 87 | + calls: Mutex<Vec<Call>>, |
| 88 | + } |
| 89 | + |
| 90 | + impl RecordingSink { |
| 91 | + fn calls(&self) -> Vec<Call> { |
| 92 | + self.calls.lock().expect("calls mutex").clone() |
| 93 | + } |
| 94 | + |
| 95 | + fn push(&self, call: Call) { |
| 96 | + self.calls.lock().expect("calls mutex").push(call); |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + impl EffectSink for RecordingSink { |
| 101 | + fn emit_state_changed(&self, payload: &StatePayload) { |
| 102 | + self.push(Call::EmitStateChanged( |
| 103 | + payload.state.clone(), |
| 104 | + payload.remaining_secs, |
| 105 | + )); |
| 106 | + } |
| 107 | + fn show_tip_windows(&self) { |
| 108 | + self.push(Call::ShowTipWindows); |
| 109 | + } |
| 110 | + fn hide_tip_windows(&self) { |
| 111 | + self.push(Call::HideTipWindows); |
| 112 | + } |
| 113 | + fn play_sound(&self, sound: SoundType) { |
| 114 | + self.push(Call::PlaySound(sound)); |
| 115 | + } |
| 116 | + fn update_tray_tooltip(&self, tooltip: TrayTooltip) { |
| 117 | + self.push(Call::UpdateTrayTooltip(tooltip)); |
| 118 | + } |
| 119 | + fn update_tray_state_icon(&self, state: TimerState) { |
| 120 | + self.push(Call::UpdateTrayStateIcon(state)); |
| 121 | + } |
| 122 | + fn reset_work_timer(&self, duration: Duration) { |
| 123 | + self.push(Call::ResetWorkTimer(duration)); |
| 124 | + } |
| 125 | + fn record_rest_session(&self, session: &RestSessionDraft) { |
| 126 | + self.push(Call::RecordRestSession(session.duration_secs)); |
| 127 | + } |
| 128 | + fn record_cycle_event(&self, event: &CycleEventDraft) { |
| 129 | + self.push(Call::RecordCycleEvent(event.outcome)); |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + fn sample_state_payload() -> StatePayload { |
| 134 | + StatePayload { |
| 135 | + state: "working".to_string(), |
| 136 | + remaining_secs: 1200, |
| 137 | + work_minutes: 20, |
| 138 | + rest_seconds: 20, |
| 139 | + mode: TimerMode::TwentyTwentyTwenty, |
| 140 | + pomodoro: None, |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + #[test] |
| 145 | + fn no_sink_logs_stub_and_returns() { |
| 146 | + // Should not panic when sink is None. |
| 147 | + execute_effect(None, &Effect::ShowTipWindows); |
| 148 | + execute_effect(None, &Effect::HideTipWindows); |
| 149 | + } |
| 150 | + |
| 151 | + #[test] |
| 152 | + fn emit_state_changed_dispatches() { |
| 153 | + let sink = RecordingSink::default(); |
| 154 | + execute_effect( |
| 155 | + Some(&sink), |
| 156 | + &Effect::EmitStateChanged(sample_state_payload()), |
| 157 | + ); |
| 158 | + assert_eq!( |
| 159 | + sink.calls(), |
| 160 | + vec![Call::EmitStateChanged("working".to_string(), 1200)] |
| 161 | + ); |
| 162 | + } |
| 163 | + |
| 164 | + #[test] |
| 165 | + fn show_and_hide_tip_windows_dispatch() { |
| 166 | + let sink = RecordingSink::default(); |
| 167 | + execute_effect(Some(&sink), &Effect::ShowTipWindows); |
| 168 | + execute_effect(Some(&sink), &Effect::HideTipWindows); |
| 169 | + assert_eq!( |
| 170 | + sink.calls(), |
| 171 | + vec![Call::ShowTipWindows, Call::HideTipWindows] |
| 172 | + ); |
| 173 | + } |
| 174 | + |
| 175 | + #[test] |
| 176 | + fn play_sound_routes_variants_distinctly() { |
| 177 | + let sink = RecordingSink::default(); |
| 178 | + execute_effect(Some(&sink), &Effect::PlaySound(SoundType::PreAlert)); |
| 179 | + execute_effect(Some(&sink), &Effect::PlaySound(SoundType::RestComplete)); |
| 180 | + assert_eq!( |
| 181 | + sink.calls(), |
| 182 | + vec![ |
| 183 | + Call::PlaySound(SoundType::PreAlert), |
| 184 | + Call::PlaySound(SoundType::RestComplete), |
| 185 | + ] |
| 186 | + ); |
| 187 | + } |
| 188 | + |
| 189 | + #[test] |
| 190 | + fn update_tray_routes_tooltip_and_state_icon_separately() { |
| 191 | + let sink = RecordingSink::default(); |
| 192 | + let tooltip = TrayTooltip { |
| 193 | + state: TimerState::Working, |
| 194 | + remaining_secs: Some(60), |
| 195 | + pomodoro_progress: Some(PomodoroProgress { |
| 196 | + current: 2, |
| 197 | + total: 4, |
| 198 | + }), |
| 199 | + }; |
| 200 | + execute_effect( |
| 201 | + Some(&sink), |
| 202 | + &Effect::UpdateTray(TrayUpdate::Tooltip(tooltip)), |
| 203 | + ); |
| 204 | + execute_effect( |
| 205 | + Some(&sink), |
| 206 | + &Effect::UpdateTray(TrayUpdate::StateIcon(TimerState::Paused)), |
| 207 | + ); |
| 208 | + assert_eq!( |
| 209 | + sink.calls(), |
| 210 | + vec![ |
| 211 | + Call::UpdateTrayTooltip(tooltip), |
| 212 | + Call::UpdateTrayStateIcon(TimerState::Paused), |
| 213 | + ] |
| 214 | + ); |
| 215 | + } |
| 216 | + |
| 217 | + #[test] |
| 218 | + fn reset_work_timer_passes_duration() { |
| 219 | + let sink = RecordingSink::default(); |
| 220 | + execute_effect( |
| 221 | + Some(&sink), |
| 222 | + &Effect::ResetWorkTimer(Duration::from_mins(20)), |
| 223 | + ); |
| 224 | + assert_eq!( |
| 225 | + sink.calls(), |
| 226 | + vec![Call::ResetWorkTimer(Duration::from_mins(20))] |
| 227 | + ); |
| 228 | + } |
| 229 | + |
| 230 | + #[test] |
| 231 | + fn record_rest_session_borrows_draft() { |
| 232 | + let sink = RecordingSink::default(); |
| 233 | + let session = RestSessionDraft { |
| 234 | + started_at_utc: Utc::now(), |
| 235 | + ended_at_utc: Utc::now(), |
| 236 | + duration_secs: 20, |
| 237 | + }; |
| 238 | + execute_effect(Some(&sink), &Effect::RecordRestSession(session)); |
| 239 | + assert_eq!(sink.calls(), vec![Call::RecordRestSession(20)]); |
| 240 | + } |
| 241 | + |
| 242 | + #[test] |
| 243 | + fn record_cycle_event_borrows_draft() { |
| 244 | + let sink = RecordingSink::default(); |
| 245 | + let event = CycleEventDraft { |
| 246 | + occurred_at_utc: Utc::now(), |
| 247 | + outcome: CycleOutcome::Suppressed, |
| 248 | + reason: None, |
| 249 | + process_hint: None, |
| 250 | + duration_secs: None, |
| 251 | + mode: TimerMode::TwentyTwentyTwenty, |
| 252 | + is_long_break: false, |
| 253 | + }; |
| 254 | + execute_effect(Some(&sink), &Effect::RecordCycleEvent(event)); |
| 255 | + assert_eq!( |
| 256 | + sink.calls(), |
| 257 | + vec![Call::RecordCycleEvent(CycleOutcome::Suppressed)] |
| 258 | + ); |
| 259 | + } |
14 | 260 | } |
0 commit comments