Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
442 changes: 365 additions & 77 deletions src-tauri/src/services/context.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src-tauri/src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ pub(crate) mod stat;
pub(crate) mod timer;
#[cfg(not(test))]
pub(crate) mod tray;
pub(crate) mod tray_tooltip;
#[cfg(not(test))]
pub(crate) mod window;
pub(crate) mod window_layout;

#[cfg(not(test))]
use std::sync::Arc;
Expand Down
258 changes: 252 additions & 6 deletions src-tauri/src/services/timer/effect_executor.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,260 @@
use tracing::info;

use super::effect::Effect;
use crate::services::ServiceContext;
use super::effect::{Effect, SoundType, TrayUpdate};
use super::state::TimerState;
use crate::models::statistics::{CycleEventDraft, RestSessionDraft};
use crate::models::types::StatePayload;

/// Execute timer effects by dispatching to concrete services.
pub(crate) fn execute_effect(app: Option<&ServiceContext>, effect: &Effect) {
let Some(app) = app else {
/// Sink that executes side effects emitted by the pure timer core.
///
/// Production wires this to `ServiceContext`, which dispatches to the real
/// Tauri-backed services (window / sound / tray / stat). Tests use a recording
/// fake (`RecordingSink` in `#[cfg(test)]`) to assert effect-to-call mapping
/// without needing an `AppHandle`.
///
/// One method per `Effect` variant on purpose: a single dispatch `fn(&Effect)`
/// would push the match into every fake, which CLAUDE.md forbids ("flag-driven
/// branching"). Granular methods also make fakes record structured payloads
/// rather than stringified `Debug`.
pub(crate) trait EffectSink: Send + Sync {
fn emit_state_changed(&self, payload: &StatePayload);
fn show_tip_windows(&self);
fn hide_tip_windows(&self);
fn play_sound(&self, sound: SoundType);
fn update_tray_tooltip(&self, tooltip: super::effect::TrayTooltip);
fn update_tray_state_icon(&self, state: TimerState);
fn reset_work_timer(&self, duration: std::time::Duration);
fn record_rest_session(&self, session: &RestSessionDraft);
fn record_cycle_event(&self, event: &CycleEventDraft);
}

/// Dispatch a single timer `Effect` to the supplied sink.
///
/// `sink: Option<&dyn EffectSink>` lets the timer service run without a
/// runtime context (early boot, tests using `TimerService` directly) — in that
/// case the effect is logged but not executed, matching the pre-refactor
/// `STUB effect:` behavior.
pub(crate) fn execute_effect(sink: Option<&dyn EffectSink>, effect: &Effect) {
let Some(sink) = sink else {
info!("STUB effect: {effect:?}");
return;
};

app.execute_timer_effect(effect);
match effect {
Effect::EmitStateChanged(payload) => sink.emit_state_changed(payload),
Effect::ShowTipWindows => sink.show_tip_windows(),
Effect::HideTipWindows => sink.hide_tip_windows(),
Effect::PlaySound(sound) => sink.play_sound(*sound),
Effect::UpdateTray(update) => match update {
TrayUpdate::Tooltip(tooltip) => sink.update_tray_tooltip(*tooltip),
TrayUpdate::StateIcon(state) => sink.update_tray_state_icon(*state),
},
Effect::ResetWorkTimer(duration) => sink.reset_work_timer(*duration),
Effect::RecordRestSession(session) => sink.record_rest_session(session),
Effect::RecordCycleEvent(event) => sink.record_cycle_event(event),
}
}

#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::time::Duration;

use chrono::Utc;

use super::*;
use crate::models::config::TimerMode;
use crate::models::statistics::CycleOutcome;
use crate::services::timer::effect::{PomodoroProgress, TrayTooltip};

/// Recorded effect-call for `RecordingSink` assertions. One variant per
/// `EffectSink` method so test failures point at the right boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Call {
EmitStateChanged(String, u32),
ShowTipWindows,
HideTipWindows,
PlaySound(SoundType),
UpdateTrayTooltip(TrayTooltip),
UpdateTrayStateIcon(TimerState),
ResetWorkTimer(Duration),
RecordRestSession(u32),
RecordCycleEvent(CycleOutcome),
}

#[derive(Default)]
struct RecordingSink {
calls: Mutex<Vec<Call>>,
}

impl RecordingSink {
fn calls(&self) -> Vec<Call> {
self.calls.lock().expect("calls mutex").clone()
}

fn push(&self, call: Call) {
self.calls.lock().expect("calls mutex").push(call);
}
}

impl EffectSink for RecordingSink {
fn emit_state_changed(&self, payload: &StatePayload) {
self.push(Call::EmitStateChanged(
payload.state.clone(),
payload.remaining_secs,
));
}
fn show_tip_windows(&self) {
self.push(Call::ShowTipWindows);
}
fn hide_tip_windows(&self) {
self.push(Call::HideTipWindows);
}
fn play_sound(&self, sound: SoundType) {
self.push(Call::PlaySound(sound));
}
fn update_tray_tooltip(&self, tooltip: TrayTooltip) {
self.push(Call::UpdateTrayTooltip(tooltip));
}
fn update_tray_state_icon(&self, state: TimerState) {
self.push(Call::UpdateTrayStateIcon(state));
}
fn reset_work_timer(&self, duration: Duration) {
self.push(Call::ResetWorkTimer(duration));
}
fn record_rest_session(&self, session: &RestSessionDraft) {
self.push(Call::RecordRestSession(session.duration_secs));
}
fn record_cycle_event(&self, event: &CycleEventDraft) {
self.push(Call::RecordCycleEvent(event.outcome));
}
}

fn sample_state_payload() -> StatePayload {
StatePayload {
state: "working".to_string(),
remaining_secs: 1200,
work_minutes: 20,
rest_seconds: 20,
mode: TimerMode::TwentyTwentyTwenty,
pomodoro: None,
}
}

#[test]
fn no_sink_logs_stub_and_returns() {
// Should not panic when sink is None.
execute_effect(None, &Effect::ShowTipWindows);
execute_effect(None, &Effect::HideTipWindows);
}

#[test]
fn emit_state_changed_dispatches() {
let sink = RecordingSink::default();
execute_effect(
Some(&sink),
&Effect::EmitStateChanged(sample_state_payload()),
);
assert_eq!(
sink.calls(),
vec![Call::EmitStateChanged("working".to_string(), 1200)]
);
}

#[test]
fn show_and_hide_tip_windows_dispatch() {
let sink = RecordingSink::default();
execute_effect(Some(&sink), &Effect::ShowTipWindows);
execute_effect(Some(&sink), &Effect::HideTipWindows);
assert_eq!(
sink.calls(),
vec![Call::ShowTipWindows, Call::HideTipWindows]
);
}

#[test]
fn play_sound_routes_variants_distinctly() {
let sink = RecordingSink::default();
execute_effect(Some(&sink), &Effect::PlaySound(SoundType::PreAlert));
execute_effect(Some(&sink), &Effect::PlaySound(SoundType::RestComplete));
assert_eq!(
sink.calls(),
vec![
Call::PlaySound(SoundType::PreAlert),
Call::PlaySound(SoundType::RestComplete),
]
);
}

#[test]
fn update_tray_routes_tooltip_and_state_icon_separately() {
let sink = RecordingSink::default();
let tooltip = TrayTooltip {
state: TimerState::Working,
remaining_secs: Some(60),
pomodoro_progress: Some(PomodoroProgress {
current: 2,
total: 4,
}),
};
execute_effect(
Some(&sink),
&Effect::UpdateTray(TrayUpdate::Tooltip(tooltip)),
);
execute_effect(
Some(&sink),
&Effect::UpdateTray(TrayUpdate::StateIcon(TimerState::Paused)),
);
assert_eq!(
sink.calls(),
vec![
Call::UpdateTrayTooltip(tooltip),
Call::UpdateTrayStateIcon(TimerState::Paused),
]
);
}

#[test]
fn reset_work_timer_passes_duration() {
let sink = RecordingSink::default();
execute_effect(
Some(&sink),
&Effect::ResetWorkTimer(Duration::from_mins(20)),
);
assert_eq!(
sink.calls(),
vec![Call::ResetWorkTimer(Duration::from_mins(20))]
);
}

#[test]
fn record_rest_session_borrows_draft() {
let sink = RecordingSink::default();
let session = RestSessionDraft {
started_at_utc: Utc::now(),
ended_at_utc: Utc::now(),
duration_secs: 20,
};
execute_effect(Some(&sink), &Effect::RecordRestSession(session));
assert_eq!(sink.calls(), vec![Call::RecordRestSession(20)]);
}

#[test]
fn record_cycle_event_borrows_draft() {
let sink = RecordingSink::default();
let event = CycleEventDraft {
occurred_at_utc: Utc::now(),
outcome: CycleOutcome::Suppressed,
reason: None,
process_hint: None,
duration_secs: None,
mode: TimerMode::TwentyTwentyTwenty,
is_long_break: false,
};
execute_effect(Some(&sink), &Effect::RecordCycleEvent(event));
assert_eq!(
sink.calls(),
vec![Call::RecordCycleEvent(CycleOutcome::Suppressed)]
);
}
}
3 changes: 2 additions & 1 deletion src-tauri/src/services/timer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ pub(crate) mod machine;
pub(crate) mod service;
pub(crate) mod state;

pub(crate) use effect::{Effect, SoundType, TrayTooltip, TrayUpdate};
pub(crate) use effect::{Effect, SoundType, TrayTooltip};
pub(crate) use effect_executor::EffectSink;
pub(crate) use machine::{apply_transition_and_collect_effects, collect_tick_effects, step_time};
pub(crate) use service::TimerService;
pub(crate) use state::{Inner, SkipFlags, TimerState, UserEvent};
10 changes: 8 additions & 2 deletions src-tauri/src/services/timer/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ impl TimerService {

let app = self.app.lock().await.clone();
for effect in &effects {
effect_executor::execute_effect(app.as_ref(), effect);
effect_executor::execute_effect(
app.as_ref().map(|c| c as &dyn super::EffectSink),
effect,
);
}

effects
Expand Down Expand Up @@ -85,7 +88,10 @@ impl TimerService {

let app = self.app.lock().await.clone();
for effect in &effects {
effect_executor::execute_effect(app.as_ref(), effect);
effect_executor::execute_effect(
app.as_ref().map(|c| c as &dyn super::EffectSink),
effect,
);
}

Ok(())
Expand Down
41 changes: 4 additions & 37 deletions src-tauri/src/services/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,7 @@ impl TrayService {
tooltip.state = state;
Self::store_tooltip(&self.current_tooltip, tooltip);

let text = if is_paused {
self.i18n.get("tray.resume")
} else {
self.i18n.get("tray.pause")
};
let text = super::tray_tooltip::pause_menu_text(&self.i18n, is_paused);

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

fn render_tooltip_text(&self, tooltip: TrayTooltip) -> String {
Self::render_tooltip(&self.i18n, tooltip)
super::tray_tooltip::render_tooltip(&self.i18n, tooltip)
}

fn render_tooltip(i18n: &I18nService, tooltip: TrayTooltip) -> String {
let app_name = i18n.get("tray.tooltip.app_name");
let label = match tooltip.state {
TimerState::Working => i18n.get("tray.tooltip.working"),
TimerState::PreAlert => i18n.get("tray.tooltip.pre_alert"),
TimerState::Alerting => i18n.get("tray.tooltip.alerting"),
TimerState::Resting => i18n.get("tray.tooltip.resting"),
TimerState::Paused => i18n.get("tray.tooltip.paused"),
};

let pomodoro_suffix = tooltip.pomodoro_progress.map_or(String::new(), |progress| {
format!(" (Pomo {}/{})", progress.current, progress.total)
});

match tooltip.remaining_secs {
Some(remaining_secs) => format!(
"{app_name} - {label} {}{pomodoro_suffix}",
Self::format_remaining(remaining_secs)
),
None => format!("{app_name} - {label}{pomodoro_suffix}"),
}
}

fn format_remaining(total_secs: u32) -> String {
let minutes = total_secs / 60;
let seconds = total_secs % 60;
format!("{minutes:02}:{seconds:02}")
super::tray_tooltip::render_tooltip(i18n, tooltip)
}

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

if let Some(item) = items.pause {
let text = if is_paused {
i18n.get("tray.resume")
} else {
i18n.get("tray.pause")
};
let text = super::tray_tooltip::pause_menu_text(i18n, is_paused);
if let Err(err) = item.set_text(text) {
warn!("failed to update pause item text: {err}");
}
Expand Down
Loading
Loading