diff --git a/.gitignore b/.gitignore index 318c59c..56a8962 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /target **/*.rs.bk .idea/ +coverage/ +lcov.info diff --git a/src/config.rs b/src/config.rs index 2497af1..d840978 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,8 +30,18 @@ pub struct Config { } impl Config { + pub fn new(activity_paths: Vec) -> Self { + let mut config = Self::default(); + for path in activity_paths { + config.add_activity_path(path); + } + config + } + fn add_activity_path(&mut self, p: PathBuf) { - self.activity_paths.insert(canonicalize(p).unwrap()); + if let Ok(canonical_path) = canonicalize(p) { + self.activity_paths.insert(canonical_path); + } } pub fn get_file_paths(&self) -> BTreeSet { @@ -394,6 +404,13 @@ mod tests { Some(&Quadrant::Q1) ); } + + #[test] + fn test_add_activity_path_non_existent() { + let mut config = Config::default(); + config.add_activity_path(PathBuf::from("non_existent_path")); + assert!(config.get_file_paths().is_empty()); + } } pub enum MetaCategory<'a> { diff --git a/src/main.rs b/src/main.rs index acb049f..826515e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,3 +142,40 @@ fn main() -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use tempfile::NamedTempFile; + use std::collections::BTreeSet; + use std::path::PathBuf; + use std::thread; + use std::time::Duration; + + #[test] + fn test_main_loop_basic() { + let mut activity_file = NamedTempFile::new().unwrap(); + writeln!(activity_file, "2024-01-01 10:00\n1 0 Task 1 @work").unwrap(); + + let plan_file = NamedTempFile::new().unwrap(); + let summary_file = NamedTempFile::new().unwrap(); + + let mut config = Config::new(vec![activity_file.path().to_path_buf()]); + config.plan_out = Some(plan_file.path().to_str().unwrap().to_string()); + config.summary_out = Some(summary_file.path().to_str().unwrap().to_string()); + config.quiet = true; + + let result = main_loop(&config); + assert!(result.is_ok()); + + let plan_output = std::fs::read_to_string(plan_file.path()).unwrap(); + assert!(plan_output.contains("-- 10:00 2024-01-01")); + assert!(plan_output.contains("-> 11h00 Task 1 @work")); + + let summary_output = std::fs::read_to_string(summary_file.path()).unwrap(); + assert!(!summary_output.contains("2024-01-01+00:00 (summary)")); + assert!(summary_output.contains("(all past summaries)")); + assert!(summary_output.contains("@work: 01h00")); + } +} diff --git a/src/notification.rs b/src/notification.rs index d989b57..3fbc7fc 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -13,8 +13,35 @@ use crate::parse::{LifeChunk, TimedLifeChunk}; use notify_rust::Notification; use std::thread::JoinHandle; -pub fn spawn_notification_thread(q: VecDeque, rx: Receiver<()>) -> JoinHandle<()> { - // XXX: should join somehow +pub trait NotificationHandler { + fn show_notification(&self, summary: &str, body: &str) -> Result<(), notify_rust::error::Error>; +} + +struct DefaultNotificationHandler; + +impl NotificationHandler for DefaultNotificationHandler { + fn show_notification(&self, summary: &str, body: &str) -> Result<(), notify_rust::error::Error> { + Notification::new() + .summary(summary) + .body(body) + .timeout(10000) + .show() + .map(|_| ()) + } +} + +pub fn spawn_notification_thread( + q: VecDeque, + rx: Receiver<()>, +) -> JoinHandle<()> { + spawn_notification_thread_with_handler(q, rx, DefaultNotificationHandler) +} + +pub fn spawn_notification_thread_with_handler( + q: VecDeque, + rx: Receiver<()>, + notification_handler: T, +) -> JoinHandle<()> { let mut lc_ls = VecDeque::new(); for tlc in q { lc_ls.push_back((tlc.start, tlc.life_chunk)); @@ -22,52 +49,49 @@ pub fn spawn_notification_thread(q: VecDeque, rx: Receiver<()>) thread::spawn(move || { while let Some((t, lc)) = lc_ls.pop_front() { - let notify = || notify(lc); - - { - let upper_bound = Local::now() - Duration::minutes(5); - if t < upper_bound { - continue; - } else if t < Local::now() { - notify(); - continue; + let notify = || notify(¬ification_handler, lc.clone()); + + let now = Local::now(); + let upper_bound = now - Duration::minutes(5); + + if t < upper_bound { + continue; + } else if t <= now { + if notify() { + return; } + continue; } - // XXX: probably buggy in case of timeout of 0 let delay = Duration::minutes(5); - let time_to_notification = unwrap_dur((t - Local::now() - delay).to_std()); - match rx.recv_timeout(time_to_notification) { - Ok(()) | Err(RecvTimeoutError::Disconnected) => { - return; - } - Err(RecvTimeoutError::Timeout) => { - if notify() { - return; + let time_to_notification = (t - now - delay).to_std(); + + if let Ok(time_to_notification) = time_to_notification { + match rx.recv_timeout(time_to_notification) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => return, + Err(RecvTimeoutError::Timeout) => { + if notify() { + return; + } } } } } - notify_nothing(); + notify_nothing(¬ification_handler); }) } -fn notify(lc: LifeChunk) -> bool { - let mut n = Notification::new(); - n.summary("Tiro: Time to switch!").timeout(10000); - n.body(&format!("Next activity: {}", lc.get_input())); - let r = n.show(); - r.is_err() +fn notify(notification_handler: &T, lc: LifeChunk) -> bool { + notification_handler + .show_notification("Tiro: Time to switch!", &format!("Next activity: {}", lc.get_input())) + .is_err() } -fn notify_nothing() -> bool { - let mut n = Notification::new(); - n.summary("Tiro: No activity planned!").timeout(100_000); - - let r = n.show(); - - r.is_err() +fn notify_nothing(notification_handler: &T) -> bool { + notification_handler + .show_notification("Tiro: No activity planned!", "") + .is_err() } fn unwrap_dur(r: Result) -> StdDuration { @@ -76,3 +100,95 @@ fn unwrap_dur(r: Result) -> StdDuration { Err(_) => StdDuration::new(0, 0), } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use crate::parse::LifeChunk; + use std::sync::{Arc, Mutex}; + use notify_rust::error::Error; + + struct MockNotificationHandler { + invocations: Arc>>, + } + + impl NotificationHandler for MockNotificationHandler { + fn show_notification(&self, summary: &str, body: &str) -> Result<(), Error> { + let mut invocations = self.invocations.lock().unwrap(); + invocations.push((summary.to_string(), body.to_string())); + Ok(()) + } + } + + #[test] + fn test_notify() { + let invocations = Arc::new(Mutex::new(Vec::new())); + let handler = MockNotificationHandler { + invocations: invocations.clone(), + }; + let lc = LifeChunk::new( + "test input".to_string(), + time::Duration::minutes(1), + vec![], + crate::config::Quadrant::Q1, + false, + "test input".to_string(), + ); + + notify(&handler, lc); + + let invocations = invocations.lock().unwrap(); + assert_eq!(invocations.len(), 1); + assert_eq!(invocations[0].0, "Tiro: Time to switch!"); + assert_eq!(invocations[0].1, "Next activity: test input"); + } + + #[test] + fn test_notify_nothing() { + let invocations = Arc::new(Mutex::new(Vec::new())); + let handler = MockNotificationHandler { + invocations: invocations.clone(), + }; + + notify_nothing(&handler); + + let invocations = invocations.lock().unwrap(); + assert_eq!(invocations.len(), 1); + assert_eq!(invocations[0].0, "Tiro: No activity planned!"); + assert_eq!(invocations[0].1, ""); + } + + #[test] + fn test_spawn_notification_thread_with_handler() { + let invocations = Arc::new(Mutex::new(Vec::new())); + let handler = MockNotificationHandler { + invocations: invocations.clone(), + }; + let (_tx, rx) = std::sync::mpsc::channel(); + + let q = VecDeque::new(); + + let handle = spawn_notification_thread_with_handler(q, rx, handler); + handle.join().unwrap(); + + let invocations = invocations.lock().unwrap(); + assert_eq!(invocations.len(), 1); + assert_eq!(invocations[0].0, "Tiro: No activity planned!"); + } + + #[test] + fn test_unwrap_dur_ok() { + let dur = StdDuration::new(1, 0); + let result = Ok(dur); + assert_eq!(unwrap_dur(result), dur); + } + + #[test] + fn test_unwrap_dur_err() { + let neg_chrono_dur = Duration::seconds(-1); + let result = neg_chrono_dur.to_std(); + assert!(result.is_err()); + assert_eq!(unwrap_dur(result), StdDuration::new(0, 0)); + } +} diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index a4554b5..418473c 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -49,7 +49,7 @@ fn cli_basic_run_with_activities_file_outputs_to_stdout() { -> 11h00 Task 1 @work -> 13h00 Task 2 @home - 2020-12-01+01:00 (summary) + 2020-12-01+00:00 (summary) @home: 02h00 @work: 01h00 @@ -232,11 +232,11 @@ fn cli_outputs_both_summary_and_global_summary() { -> 12h00 Development @project -> 13h00 Review @meeting - 2020-12-01+01:00 (summary) + 2020-12-01+00:00 (summary) @meeting: 01h30 @project: 02h00 - 2020-12-02+01:00 (summary) + 2020-12-02+00:00 (summary) @meeting: 01h00 @project: 03h00 @@ -374,7 +374,7 @@ fn cli_with_stdin_input_processes_activities() { -- 09:00 2020-12-01 -> 10h00 Morning task @urgent - 2020-12-01+01:00 (summary) + 2020-12-01+00:00 (summary) @urgent: 01h00 (all past summaries)