Skip to content

Commit 36cf175

Browse files
rsecssclaude
andauthored
refactor(services): extract pure helpers and EffectSink trait (F23) (#31)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e97b055 commit 36cf175

9 files changed

Lines changed: 1019 additions & 137 deletions

File tree

src-tauri/src/services/context.rs

Lines changed: 365 additions & 77 deletions
Large diffs are not rendered by default.

src-tauri/src/services/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ pub(crate) mod stat;
99
pub(crate) mod timer;
1010
#[cfg(not(test))]
1111
pub(crate) mod tray;
12+
pub(crate) mod tray_tooltip;
1213
#[cfg(not(test))]
1314
pub(crate) mod window;
15+
pub(crate) mod window_layout;
1416

1517
#[cfg(not(test))]
1618
use std::sync::Arc;
Lines changed: 252 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,260 @@
11
use tracing::info;
22

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;
57

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 {
939
info!("STUB effect: {effect:?}");
1040
return;
1141
};
1242

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+
}
14260
}

src-tauri/src/services/timer/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ pub(crate) mod machine;
44
pub(crate) mod service;
55
pub(crate) mod state;
66

7-
pub(crate) use effect::{Effect, SoundType, TrayTooltip, TrayUpdate};
7+
pub(crate) use effect::{Effect, SoundType, TrayTooltip};
8+
pub(crate) use effect_executor::EffectSink;
89
pub(crate) use machine::{apply_transition_and_collect_effects, collect_tick_effects, step_time};
910
pub(crate) use service::TimerService;
1011
pub(crate) use state::{Inner, SkipFlags, TimerState, UserEvent};

src-tauri/src/services/timer/service.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ impl TimerService {
5757

5858
let app = self.app.lock().await.clone();
5959
for effect in &effects {
60-
effect_executor::execute_effect(app.as_ref(), effect);
60+
effect_executor::execute_effect(
61+
app.as_ref().map(|c| c as &dyn super::EffectSink),
62+
effect,
63+
);
6164
}
6265

6366
effects
@@ -85,7 +88,10 @@ impl TimerService {
8588

8689
let app = self.app.lock().await.clone();
8790
for effect in &effects {
88-
effect_executor::execute_effect(app.as_ref(), effect);
91+
effect_executor::execute_effect(
92+
app.as_ref().map(|c| c as &dyn super::EffectSink),
93+
effect,
94+
);
8995
}
9096

9197
Ok(())

src-tauri/src/services/tray.rs

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -286,11 +286,7 @@ impl TrayService {
286286
tooltip.state = state;
287287
Self::store_tooltip(&self.current_tooltip, tooltip);
288288

289-
let text = if is_paused {
290-
self.i18n.get("tray.resume")
291-
} else {
292-
self.i18n.get("tray.pause")
293-
};
289+
let text = super::tray_tooltip::pause_menu_text(&self.i18n, is_paused);
294290

295291
let items = Self::menu_items_snapshot(&self.menu_items);
296292
if let Some(item) = items.pause {
@@ -340,36 +336,11 @@ impl TrayService {
340336
}
341337

342338
fn render_tooltip_text(&self, tooltip: TrayTooltip) -> String {
343-
Self::render_tooltip(&self.i18n, tooltip)
339+
super::tray_tooltip::render_tooltip(&self.i18n, tooltip)
344340
}
345341

346342
fn render_tooltip(i18n: &I18nService, tooltip: TrayTooltip) -> String {
347-
let app_name = i18n.get("tray.tooltip.app_name");
348-
let label = match tooltip.state {
349-
TimerState::Working => i18n.get("tray.tooltip.working"),
350-
TimerState::PreAlert => i18n.get("tray.tooltip.pre_alert"),
351-
TimerState::Alerting => i18n.get("tray.tooltip.alerting"),
352-
TimerState::Resting => i18n.get("tray.tooltip.resting"),
353-
TimerState::Paused => i18n.get("tray.tooltip.paused"),
354-
};
355-
356-
let pomodoro_suffix = tooltip.pomodoro_progress.map_or(String::new(), |progress| {
357-
format!(" (Pomo {}/{})", progress.current, progress.total)
358-
});
359-
360-
match tooltip.remaining_secs {
361-
Some(remaining_secs) => format!(
362-
"{app_name} - {label} {}{pomodoro_suffix}",
363-
Self::format_remaining(remaining_secs)
364-
),
365-
None => format!("{app_name} - {label}{pomodoro_suffix}"),
366-
}
367-
}
368-
369-
fn format_remaining(total_secs: u32) -> String {
370-
let minutes = total_secs / 60;
371-
let seconds = total_secs % 60;
372-
format!("{minutes:02}:{seconds:02}")
343+
super::tray_tooltip::render_tooltip(i18n, tooltip)
373344
}
374345

375346
fn apply_tooltip(app: &AppHandle, i18n: &I18nService, tooltip: TrayTooltip) {
@@ -384,11 +355,7 @@ impl TrayService {
384355
let items = Self::menu_items_snapshot(menu_items);
385356

386357
if let Some(item) = items.pause {
387-
let text = if is_paused {
388-
i18n.get("tray.resume")
389-
} else {
390-
i18n.get("tray.pause")
391-
};
358+
let text = super::tray_tooltip::pause_menu_text(i18n, is_paused);
392359
if let Err(err) = item.set_text(text) {
393360
warn!("failed to update pause item text: {err}");
394361
}

0 commit comments

Comments
 (0)