diff --git a/Cargo.lock b/Cargo.lock index e5172af..1969600 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -122,6 +137,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -225,6 +251,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -351,6 +388,12 @@ dependencies = [ "syn", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "dispatch2" version = "0.3.1" @@ -370,6 +413,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -401,6 +450,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -490,6 +554,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -677,6 +753,12 @@ dependencies = [ "libc", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-bigint" version = "0.4.6" @@ -907,6 +989,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1169,6 +1281,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" @@ -1345,6 +1463,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -1422,11 +1559,14 @@ dependencies = [ name = "timer-cli" version = "0.11.2" dependencies = [ + "assert_cmd", "clap", "crossterm", "glob", + "insta", "libc", "nix", + "predicates", "regex", "rodio", "signal-hook 0.4.4", @@ -1487,6 +1627,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index c40271f..aae8e37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,9 @@ libc = "0.2" nix = { version = "0.31", features = ["ioctl"] } [dev-dependencies] +assert_cmd = "2" +insta = "1" +predicates = "3" time = { version = "0.3", features = ["macros"] } diff --git a/src/alert.rs b/src/alert.rs new file mode 100644 index 0000000..198fa96 --- /dev/null +++ b/src/alert.rs @@ -0,0 +1,133 @@ +use crate::Result; + +use crate::beep::beep; +use crate::constants::{BEEP_DELAY, BEEP_DURATION, BEEP_FREQ, BEEP_REPETITIONS, SOUND_START_DELAY}; +use crate::sound::Sound; + +use std::io::Write; +#[cfg(test)] +use std::sync::{Arc, Mutex}; +use std::thread::sleep; +use std::time::Duration as stdDuration; + +pub trait Alert { + fn play(&self) -> Result<()>; +} + +pub struct BeepAlert; + +impl Alert for BeepAlert { + fn play(&self) -> Result<()> { + for _ in 0..BEEP_REPETITIONS { + sleep(stdDuration::from_millis(SOUND_START_DELAY)); + if beep(BEEP_FREQ, stdDuration::from_millis(BEEP_DURATION)).is_err() { + sleep(stdDuration::from_millis(BEEP_DURATION)); + } + let remaining_delay = BEEP_DELAY.saturating_sub(SOUND_START_DELAY); + if remaining_delay > 0 { + sleep(stdDuration::from_millis(remaining_delay)); + } + } + Ok(()) + } +} + +pub struct SoundAlert; + +impl Alert for SoundAlert { + fn play(&self) -> Result<()> { + let sound = Sound::new()?; + for _ in 0..BEEP_REPETITIONS { + sound.play()?; + sleep(stdDuration::from_millis(BEEP_DELAY)); + } + Ok(()) + } +} + +#[cfg(test)] +pub struct SilentAlert; + +#[cfg(test)] +impl Alert for SilentAlert { + fn play(&self) -> Result<()> { + Ok(()) + } +} + +pub fn write_terminal_bell(w: &mut W) -> Result<()> { + w.write_all(b"\x07")?; + w.flush()?; + Ok(()) +} + +#[cfg(test)] +#[derive(Clone)] +pub struct AlertCallLog { + pub calls: Vec<&'static str>, +} + +#[cfg(test)] +impl AlertCallLog { + pub fn new() -> Self { + Self { calls: Vec::new() } + } +} + +#[cfg(test)] +pub struct MockAlert { + log: Arc>, + name: &'static str, +} + +#[cfg(test)] +impl MockAlert { + pub fn new(log: Arc>, name: &'static str) -> Self { + Self { log, name } + } +} + +#[cfg(test)] +impl Alert for MockAlert { + fn play(&self) -> Result<()> { + self.log.lock().unwrap().calls.push(self.name); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn silent_alert_returns_ok() { + assert!(SilentAlert.play().is_ok()); + } + + #[test] + fn write_terminal_bell_writes_bell_char() { + let mut buf = Vec::new(); + write_terminal_bell(&mut buf).unwrap(); + assert_eq!(buf, b"\x07"); + } + + #[test] + fn mock_alert_records_calls() { + let log = Arc::new(Mutex::new(AlertCallLog::new())); + let alert = MockAlert::new(Arc::clone(&log), "beep"); + alert.play().unwrap(); + alert.play().unwrap(); + assert_eq!(log.lock().unwrap().calls, vec!["beep", "beep"]); + } + + #[test] + fn mock_alert_records_different_names() { + let log = Arc::new(Mutex::new(AlertCallLog::new())); + let a = MockAlert::new(Arc::clone(&log), "beep"); + let b = MockAlert::new(Arc::clone(&log), "sound"); + a.play().unwrap(); + b.play().unwrap(); + a.play().unwrap(); + assert_eq!(log.lock().unwrap().calls, vec!["beep", "sound", "beep"]); + } +} diff --git a/src/figlet/mod.rs b/src/figlet/mod.rs index 3c263a2..639ef2c 100644 --- a/src/figlet/mod.rs +++ b/src/figlet/mod.rs @@ -91,3 +91,40 @@ fn parse<'a>(mut iter: impl Iterator) -> Option { Some(Figlet { height, chars }) } + +#[cfg(test)] +fn trim_trailing(s: &str) -> String { + s.lines() + .map(|line| line.trim_end()) + .collect::>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_figlet_digits() { + let figlet = Figlet::default(); + for digit in 0..=9 { + let digit_str = digit.to_string(); + let result = trim_trailing(&figlet.convert(&digit_str)); + insta::assert_snapshot!(format!("digit_{}", digit), &result); + } + } + + #[test] + fn test_figlet_colon_time() { + let figlet = Figlet::default(); + let result = trim_trailing(&figlet.convert(":00:00")); + insta::assert_snapshot!("colon_time_pattern", &result); + } + + #[test] + fn test_figlet_timer_string() { + let figlet = Figlet::default(); + let result = trim_trailing(&figlet.convert("1h 30m 5s")); + insta::assert_snapshot!("timer_string", &result); + } +} diff --git a/src/figlet/snapshots/timer__figlet__tests__colon_time_pattern.snap b/src/figlet/snapshots/timer__figlet__tests__colon_time_pattern.snap new file mode 100644 index 0000000..3ad4cfb --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__colon_time_pattern.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ,a8888a, ,a8888a, ,a8888a, ,a8888a, + ,8P"' `"Y8, ,8P"' `"Y8, ,8P"' `"Y8, ,8P"' `"Y8, + ,8P Y8, ,8P Y8, ,8P Y8, ,8P Y8, +888 88 88 88 88 888 88 88 88 88 +888 88 88 88 88 888 88 88 88 88 + `8b d8' `8b d8' `8b d8' `8b d8' +888 `8ba, ,ad8' `8ba, ,ad8' 888 `8ba, ,ad8' `8ba, ,ad8' +888 "Y8888P" "Y8888P" 888 "Y8888P" "Y8888P" diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_0.snap b/src/figlet/snapshots/timer__figlet__tests__digit_0.snap new file mode 100644 index 0000000..a22327c --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_0.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ,a8888a, + ,8P"' `"Y8, +,8P Y8, +88 88 +88 88 +`8b d8' + `8ba, ,ad8' + "Y8888P" diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_1.snap b/src/figlet/snapshots/timer__figlet__tests__digit_1.snap new file mode 100644 index 0000000..2ae7e4f --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_1.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + 88 + ,d88 +888888 + 88 + 88 + 88 + 88 + 88 diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_2.snap b/src/figlet/snapshots/timer__figlet__tests__digit_2.snap new file mode 100644 index 0000000..017ac81 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_2.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ad888888b, +d8" "88 + a8P + ,d8P" + a8P" + a8P' +d8" +88888888888 diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_3.snap b/src/figlet/snapshots/timer__figlet__tests__digit_3.snap new file mode 100644 index 0000000..f0937e2 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_3.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ad888888b, +d8" "88 + a8P + aad8" + ""Y8, + "8b +Y8, a88 + "Y888888P' diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_4.snap b/src/figlet/snapshots/timer__figlet__tests__digit_4.snap new file mode 100644 index 0000000..5ed3d15 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_4.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ,d8 + ,d888 + ,d8" 88 + ,d8" 88 +,d8" 88 +8888888888888 + 88 + 88 diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_5.snap b/src/figlet/snapshots/timer__figlet__tests__digit_5.snap new file mode 100644 index 0000000..12d1653 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_5.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + +8888888888 +88 +88 ____ +88a8PPPP8b, +PP" `8b + d8 +Y8a a8P + "Y88888P" diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_6.snap b/src/figlet/snapshots/timer__figlet__tests__digit_6.snap new file mode 100644 index 0000000..66ae815 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_6.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ad8888ba, + 8P' "Y8 +d8 +88,dd888bb, +88P' `8b +88 d8 +88a a8P + "Y88888P" diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_7.snap b/src/figlet/snapshots/timer__figlet__tests__digit_7.snap new file mode 100644 index 0000000..02ab4d6 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_7.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + +888888888888 + ,8P' + d8" + ,8P' + d8" + ,8P' + d8" +8P' diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_8.snap b/src/figlet/snapshots/timer__figlet__tests__digit_8.snap new file mode 100644 index 0000000..2d705dd --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_8.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ad88888ba +d8" "8b +Y8a a8P + "Y8aaa8P" + ,d8"""8b, +d8" "8b +Y8a a8P + "Y88888P" diff --git a/src/figlet/snapshots/timer__figlet__tests__digit_9.snap b/src/figlet/snapshots/timer__figlet__tests__digit_9.snap new file mode 100644 index 0000000..377bd21 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__digit_9.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + ad88888ba +d8" "88 +8P 88 +Y8, ,d88 + "PPPPPP"88 + 8P +8b, a8P +`"Y8888P' diff --git a/src/figlet/snapshots/timer__figlet__tests__timer_string.snap b/src/figlet/snapshots/timer__figlet__tests__timer_string.snap new file mode 100644 index 0000000..97dfbf8 --- /dev/null +++ b/src/figlet/snapshots/timer__figlet__tests__timer_string.snap @@ -0,0 +1,13 @@ +--- +source: src/figlet/mod.rs +expression: "&result" +--- + + 88 88 ad888888b, ,a8888a, 8888888888 + ,d88 88 d8" "88 ,8P"' `"Y8, 88 +888888 88 a8P ,8P Y8, 88 ____ + 88 88,dPPYba, aad8" 88 88 88,dPYba,,adPYba, 88a8PPPP8b, ,adPPYba, + 88 88P' "8a ""Y8, 88 88 88P' "88" "8a PP" `8b I8[ "" + 88 88 88 "8b `8b d8' 88 88 88 d8 `"Y8ba, + 88 88 88 Y8, a88 `8ba, ,ad8' 88 88 88 Y8a a8P aa ]8I + 88 88 88 "Y888888P' "Y8888P" 88 88 88 "Y88888P" `"YbbdP"' diff --git a/src/main.rs b/src/main.rs index ad7b803..8b15879 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod alert; mod beep; mod constants; mod figlet; @@ -125,3 +126,43 @@ fn run_countdown(opts: Opts) { thread_join_handle.join().unwrap(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_time_duration_input() { + let result = parse_time("5m"); + assert!(result.is_some()); + let parsed_time = result.unwrap(); + let now = OffsetDateTime::now_utc(); + let expected_min = now + Duration::from_secs(4 * 60 + 30); // 4m30s from now + let expected_max = now + Duration::from_secs(5 * 60); // 5m from now + + // Check that the parsed time is roughly 5 minutes from now + assert!(parsed_time >= expected_min && parsed_time <= expected_max); + } + + #[test] + fn test_parse_time_target_time() { + let result = parse_time("23:59"); + assert!(result.is_some()); + let parsed_time = result.unwrap(); + // Check that the hour is 23 and minute is 59 + assert_eq!(parsed_time.hour(), 23); + assert_eq!(parsed_time.minute(), 59); + } + + #[test] + fn test_parse_time_invalid() { + let result = parse_time("invalid"); + assert!(result.is_none()); + } + + #[test] + fn test_parse_time_empty() { + let result = parse_time(""); + assert!(result.is_none()); + } +} diff --git a/src/opts.rs b/src/opts.rs index 8c934ef..8ab80e9 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -27,7 +27,7 @@ pub struct Opts { pub time: Vec, } -#[derive(Subcommand)] +#[derive(Subcommand, PartialEq, Debug)] pub enum Command { /// Start a stopwatch (counts up from zero) Stopwatch, @@ -38,3 +38,55 @@ fn verify_cli() { use clap::CommandFactory; Opts::command().debug_assert() } + +#[test] +fn test_opts_valid_duration() { + let args = ["timer", "5m"]; + let opts = Opts::try_parse_from(args).unwrap(); + assert_eq!(opts.time, vec!["5m"]); +} + +#[test] +fn test_opts_valid_hms() { + let args = ["timer", "1h30m45s"]; + let opts = Opts::try_parse_from(args).unwrap(); + assert_eq!(opts.time, vec!["1h30m45s"]); +} + +#[test] +fn test_opts_stopwatch() { + let args = ["timer", "stopwatch"]; + let opts = Opts::try_parse_from(args).unwrap(); + assert_eq!(opts.command, Some(Command::Stopwatch)); +} + +#[test] +fn test_opts_silence_flag() { + let args = ["timer", "--silence", "10s"]; + let opts = Opts::try_parse_from(args).unwrap(); + assert!(opts.silence); +} + +#[test] +fn test_opts_short_flags() { + let args = ["timer", "-l", "-s", "5m"]; + let opts = Opts::try_parse_from(args).unwrap(); + assert!(opts.r#loop); + assert!(opts.silence); +} + +#[test] +fn test_opts_invalid_flag() { + let args = ["timer", "--invalid"]; + let result = Opts::try_parse_from(args); + assert!(result.is_err()); +} + +#[test] +fn test_opts_defaults() { + let args = ["timer", "10s"]; + let opts = Opts::try_parse_from(args).unwrap(); + assert!(!opts.silence); + assert!(!opts.r#loop); + assert!(!opts.terminal_bell); +} diff --git a/src/snapshots/timer__time__tests__render_full_size.snap b/src/snapshots/timer__time__tests__render_full_size.snap new file mode 100644 index 0000000..5d38dd1 --- /dev/null +++ b/src/snapshots/timer__time__tests__render_full_size.snap @@ -0,0 +1,22 @@ +--- +source: src/time.rs +expression: "&trimmed" +--- + + + + + + + + + + + ad888888b, 88 88 ,a8888a, 8888888888 + d8" "88 88 ,d88 ,8P"' `"Y8, 88 + a8P 88 888888 ,8P Y8, 88 ____ + ,d8P" 88,dPPYba, 88 88 88 88,dPYba,,adPYba, 88a8PPPP8b, ,adPPYba, + a8P" 88P' "8a 88 88 88 88P' "88" "8a PP" `8b I8[ "" + a8P' 88 88 88 `8b d8' 88 88 88 d8 `"Y8ba, + d8" 88 88 88 `8ba, ,ad8' 88 88 88 Y8a a8P aa ]8I + 88888888888 88 88 88 "Y8888P" 88 88 88 "Y88888P" `"YbbdP"' diff --git a/src/snapshots/timer__time__tests__render_medium_size.snap b/src/snapshots/timer__time__tests__render_medium_size.snap new file mode 100644 index 0000000..3016476 --- /dev/null +++ b/src/snapshots/timer__time__tests__render_medium_size.snap @@ -0,0 +1,17 @@ +--- +source: src/time.rs +expression: "&trimmed" +--- + + + + + + ad888888b, 88 + d8" "88 88 + a8P 88 + ,d8P" 88,dPPYba, + a8P" 88P' "8a + a8P' 88 88 + d8" 88 88 + 88888888888 88 88 diff --git a/src/snapshots/timer__time__tests__render_small_size.snap b/src/snapshots/timer__time__tests__render_small_size.snap new file mode 100644 index 0000000..c61f57b --- /dev/null +++ b/src/snapshots/timer__time__tests__render_small_size.snap @@ -0,0 +1,5 @@ +--- +source: src/time.rs +expression: "&trimmed" +--- +2h 10m 5s diff --git a/src/stopwatch.rs b/src/stopwatch.rs index f7401a8..f19c599 100644 --- a/src/stopwatch.rs +++ b/src/stopwatch.rs @@ -9,8 +9,8 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::terminal; use time::Duration as TimeDuration; -/// Stopwatch state machine -enum State { +#[cfg_attr(test, derive(Debug, PartialEq))] +pub(crate) enum State { Running { start: Instant, accumulated: Duration, @@ -28,7 +28,7 @@ impl State { } } - fn toggle_pause(self) -> Self { + pub(crate) fn toggle_pause(self) -> Self { match self { State::Running { start, accumulated } => State::Paused { accumulated: accumulated + start.elapsed(), @@ -40,19 +40,18 @@ impl State { } } - fn reset() -> Self { + pub(crate) fn reset() -> Self { State::Running { start: Instant::now(), accumulated: Duration::ZERO, } } - fn is_running(&self) -> bool { + pub(crate) fn is_running(&self) -> bool { matches!(self, State::Running { .. }) } } -/// Run the stopwatch loop pub fn run(w: &mut W) -> Result<()> { terminal::enable_raw_mode()?; @@ -67,14 +66,12 @@ pub fn run(w: &mut W) -> Result<()> { let elapsed = state.elapsed(); let current_secs = elapsed.as_secs(); - // Only redraw if seconds changed (reduces flickering) if current_secs != last_drawn_secs { let time_duration = TimeDuration::new(elapsed.as_secs() as i64, 0); ui::draw_with_laps(w, time_duration, &laps, state.is_running())?; last_drawn_secs = current_secs; } - // Poll for events with a short timeout if event::poll(Duration::from_millis(50))? && let Event::Key(key_event) = event::read()? { @@ -82,27 +79,23 @@ pub fn run(w: &mut W) -> Result<()> { Action::Quit => break, Action::TogglePause => { state = state.toggle_pause(); - // Force redraw on state change last_drawn_secs = u64::MAX; } Action::Lap => { if state.is_running() { laps.push(state.elapsed()); - // Force redraw on lap last_drawn_secs = u64::MAX; } } Action::Reset => { laps.clear(); state = State::reset(); - // Force redraw on reset last_drawn_secs = u64::MAX; } Action::None => {} } } - // Small sleep to prevent busy-waiting if state.is_running() { sleep(Duration::from_millis(10)); } @@ -112,7 +105,8 @@ pub fn run(w: &mut W) -> Result<()> { Ok(()) } -enum Action { +#[cfg_attr(test, derive(Debug, PartialEq))] +pub(crate) enum Action { Quit, TogglePause, Lap, @@ -120,7 +114,7 @@ enum Action { None, } -fn handle_key(key: KeyEvent) -> Action { +pub(crate) fn handle_key(key: KeyEvent) -> Action { match key.code { KeyCode::Char('q') => Action::Quit, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit, @@ -130,3 +124,99 @@ fn handle_key(key: KeyEvent) -> Action { _ => Action::None, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn test_handle_key_space() { + let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE); + assert_eq!(handle_key(key), Action::TogglePause); + } + + #[test] + fn test_handle_key_p() { + let key = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE); + assert_eq!(handle_key(key), Action::TogglePause); + } + + #[test] + fn test_handle_key_l() { + let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE); + assert_eq!(handle_key(key), Action::Lap); + } + + #[test] + fn test_handle_key_enter() { + let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + assert_eq!(handle_key(key), Action::Lap); + } + + #[test] + fn test_handle_key_r() { + let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE); + assert_eq!(handle_key(key), Action::Reset); + } + + #[test] + fn test_handle_key_q() { + let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE); + assert_eq!(handle_key(key), Action::Quit); + } + + #[test] + fn test_handle_key_ctrl_c() { + let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(handle_key(key), Action::Quit); + } + + #[test] + fn test_handle_key_unknown() { + let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); + assert_eq!(handle_key(key), Action::None); + } + + #[test] + fn test_state_is_running_running() { + let state = State::Running { + start: Instant::now(), + accumulated: Duration::ZERO, + }; + assert!(state.is_running()); + } + + #[test] + fn test_state_is_running_paused() { + let state = State::Paused { + accumulated: Duration::ZERO, + }; + assert!(!state.is_running()); + } + + #[test] + fn test_state_reset_is_running() { + let state = State::reset(); + assert!(state.is_running()); + } + + #[test] + fn test_state_toggle_pause_running_to_paused() { + let initial_state = State::Running { + start: Instant::now(), + accumulated: Duration::ZERO, + }; + let toggled_state = initial_state.toggle_pause(); + assert!(!toggled_state.is_running()); + } + + #[test] + fn test_state_toggle_pause_paused_to_running() { + let initial_state = State::Paused { + accumulated: Duration::ZERO, + }; + let toggled_state = initial_state.toggle_pause(); + assert!(toggled_state.is_running()); + } +} diff --git a/src/time.rs b/src/time.rs index a9a82ea..fc7fae1 100644 --- a/src/time.rs +++ b/src/time.rs @@ -230,4 +230,135 @@ mod tests { ); assert_eq!(get_distance_from_top_left((100, 100), (2000, 2000)), None); } + + #[test] + fn test_format() { + // Test cases for Time::format() + assert_eq!( + Time { + hours: 0, + minutes: 0, + seconds: 0 + } + .format(), + "0s" + ); + assert_eq!( + Time { + hours: 1, + minutes: 0, + seconds: 0 + } + .format(), + "1h 0m 0s" + ); + assert_eq!( + Time { + hours: 0, + minutes: 30, + seconds: 0 + } + .format(), + "30m 0s" + ); + assert_eq!( + Time { + hours: 0, + minutes: 0, + seconds: 45 + } + .format(), + "45s" + ); + assert_eq!( + Time { + hours: 2, + minutes: 10, + seconds: 5 + } + .format(), + "2h 10m 5s" + ); + assert_eq!( + Time { + hours: 99, + minutes: 59, + seconds: 59 + } + .format(), + "99h 59m 59s" + ); + } + + #[test] + fn test_format_ruled() { + // Test cases for Time::format_ruled() + let time_with_all = Time { + hours: 2, + minutes: 10, + seconds: 5, + }; + assert_eq!(time_with_all.format_ruled(false, false), "2h 10m 5s"); // full + assert_eq!(time_with_all.format_ruled(false, true), "2h 10m"); // omit seconds + assert_eq!(time_with_all.format_ruled(true, true), "2h"); // omit minutes and seconds + + let time_minutes_only = Time { + hours: 0, + minutes: 30, + seconds: 0, + }; + assert_eq!(time_minutes_only.format_ruled(false, false), "30m 0s"); // minutes-only full + assert_eq!(time_minutes_only.format_ruled(false, true), "30m"); // minutes-only omit seconds + + let time_seconds_only = Time { + hours: 0, + minutes: 0, + seconds: 45, + }; + assert_eq!(time_seconds_only.format_ruled(false, false), "45s"); // seconds-only + assert_eq!(time_seconds_only.format_ruled(true, true), "45s"); // seconds-only with omit flags + } + + #[test] + fn test_render_full_size() { + let time = Time::from(&Duration::seconds(7805)); + let result = time.render((120, 30)); + let trimmed: String = result + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n"); + insta::assert_snapshot!("render_full_size", &trimmed); + } + + #[test] + fn test_render_medium_size() { + let time = Time::from(&Duration::seconds(7805)); + let result = time.render((60, 20)); + let trimmed: String = result + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n"); + insta::assert_snapshot!("render_medium_size", &trimmed); + } + + #[test] + fn test_render_small_size() { + let time = Time::from(&Duration::seconds(7805)); + let result = time.render((20, 10)); + let trimmed: String = result + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n"); + insta::assert_snapshot!("render_small_size", &trimmed); + } + + #[test] + fn test_try_render_too_small() { + let time = Time::from(&Duration::seconds(7805)); // 2h 10m 5s + let result = time.try_render((5, 3), false, false, true); + assert_eq!(result, None); + } } diff --git a/src/timer.rs b/src/timer.rs index f25ebfa..221c876 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -1,19 +1,16 @@ use crate::Result; -use crate::beep::beep; -use crate::constants::{BEEP_DELAY, BEEP_DURATION, BEEP_FREQ, BEEP_REPETITIONS, SOUND_START_DELAY}; +use crate::alert::{Alert, BeepAlert, SoundAlert, write_terminal_bell}; use crate::opts::Opts; -use crate::sound::Sound; use crate::ui; use std::io; +use std::sync::Arc; use std::thread::sleep; use std::time::Duration as stdDuration; use regex::{Regex, RegexSet}; use time::{Duration, OffsetDateTime, Time, format_description}; -pub const BELL_CHART: char = ''; - pub fn parse_counter_time(s: &str) -> Option { let re = Regex::new( r"^(?:(?P\d+)h ?)?(?:(?P\d+)m(?:in)? ?)?(?:(?P\d+)s? ?)?$", @@ -103,34 +100,39 @@ where } } -fn play_beep() -> Result<()> { - for _ in 0..BEEP_REPETITIONS { - sleep(stdDuration::from_millis(SOUND_START_DELAY)); - if beep(BEEP_FREQ, stdDuration::from_millis(BEEP_DURATION)).is_err() { - sleep(stdDuration::from_millis(BEEP_DURATION)); - } - - let remaining_delay = BEEP_DELAY.saturating_sub(SOUND_START_DELAY); - if remaining_delay > 0 { - sleep(stdDuration::from_millis(remaining_delay)); - } +pub fn play_completion_alerts( + w: &mut W, + opts: &Opts, + beep_alert: &B, + sound_alert: Arc, +) -> Result<()> +where + W: io::Write, + B: Alert + ?Sized, +{ + if opts.terminal_bell { + write_terminal_bell(w)?; } - Ok(()) -} -fn play_sound() -> Result<()> { - let sound = Sound::new()?; - - for _ in 0..BEEP_REPETITIONS { - sound.play()?; - sleep(stdDuration::from_millis(BEEP_DELAY)); + if !opts.silence { + let sa = Arc::clone(&sound_alert); + let sound_handle = std::thread::spawn(move || sa.play().unwrap()); + beep_alert.play()?; + sound_handle.join().map_err(|_| "Sound thread panicked")?; } Ok(()) } -pub fn countdown(w: &mut W, end: OffsetDateTime, opts: &Opts) -> Result<()> +pub fn countdown_with_alerts( + w: &mut W, + end: OffsetDateTime, + opts: &Opts, + beep_alert: &B, + sound_alert: Arc, +) -> Result<()> where W: io::Write, + B: Alert + ?Sized, { loop { match end - OffsetDateTime::now_utc() { @@ -142,28 +144,70 @@ where }, _ => { ui::draw(w, Duration::ZERO)?; - - if opts.terminal_bell { - println!("{BELL_CHART}"); - } - - if !opts.silence { - let sound_handle = std::thread::spawn(|| play_sound().unwrap()); - play_beep()?; - sound_handle.join().map_err(|_| "Sound thread panicked")?; - } + play_completion_alerts(w, opts, beep_alert, sound_alert)?; return Ok(()); } } } } +pub fn countdown(w: &mut W, end: OffsetDateTime, opts: &Opts) -> Result<()> +where + W: io::Write, +{ + countdown_with_alerts(w, end, opts, &BeepAlert, Arc::new(SoundAlert)) +} #[cfg(test)] mod tests { use super::*; - + #[cfg(test)] + use crate::alert::SilentAlert; + use crate::alert::{Alert, AlertCallLog, MockAlert}; + use crate::constants::{BEEP_DELAY, BEEP_FREQ, BEEP_REPETITIONS, SOUND_START_DELAY}; + use clap::Parser; + use std::sync::{Arc, Mutex}; use time::macros::time; + #[test] + #[allow(clippy::assertions_on_constants)] + fn test_constants_beep_freq_unchanged() { + assert_eq!(BEEP_FREQ, 440); + } + + #[test] + #[allow(clippy::assertions_on_constants)] + fn test_constants_beep_repetitions_unchanged() { + assert_eq!(BEEP_REPETITIONS, 5); + } + + #[test] + #[allow(clippy::assertions_on_constants)] + fn test_constants_sound_start_delay_less_than_beep_delay() { + assert!(SOUND_START_DELAY <= BEEP_DELAY); + } + + #[test] + fn test_countdown_uses_parallel_sound_and_beep() { + use crate::opts::Opts; + + let mut opts = Opts::try_parse_from(["timer", "1s"]).unwrap(); + opts.silence = false; + + let log = Arc::new(Mutex::new(AlertCallLog::new())); + let beep_alert = MockAlert::new(Arc::clone(&log), "beep"); + let sound_alert = MockAlert::new(Arc::clone(&log), "sound"); + let sound_alert_arc = Arc::new(sound_alert) as Arc; + + let mut output = Vec::new(); + play_completion_alerts(&mut output, &opts, &beep_alert, sound_alert_arc).unwrap(); + + let log_guard = log.lock().unwrap(); + let calls: Vec<&str> = log_guard.calls.clone(); + + assert!(calls.contains(&"beep")); + assert!(calls.contains(&"sound")); + } + #[test] fn test_parse_counter_time() { assert_eq!( @@ -192,6 +236,73 @@ mod tests { assert_eq!(None, parse_counter_time("10:00")); } + #[test] + fn test_parse_counter_time_zero() { + assert_eq!(Duration::seconds(0), parse_counter_time("0s").unwrap()); + } + + #[test] + fn test_parse_counter_time_empty() { + assert_eq!(None, parse_counter_time("")); + } + + #[test] + fn test_parse_counter_time_invalid() { + assert_eq!(None, parse_counter_time("abc")); + } + + #[test] + fn test_parse_counter_time_large() { + assert_eq!( + Duration::seconds(3599996400), + parse_counter_time("999999h").unwrap() + ); + } + + #[test] + fn test_parse_counter_time_duplicate_units() { + // This should either return Some or None, but shouldn't panic + let result = parse_counter_time("1h1h"); + // Just ensure it doesn't panic + assert!(result.is_some() || result.is_none()); + } + + #[test] + fn test_parse_counter_time_bare_number() { + assert_eq!(Duration::seconds(10), parse_counter_time("10").unwrap()); + } + + #[test] + fn test_parse_counter_time_only_hours() { + assert_eq!(Duration::seconds(18000), parse_counter_time("5h").unwrap()); + } + + #[test] + fn test_parse_counter_time_hours_minutes() { + assert_eq!( + Duration::seconds(5400), + parse_counter_time("1h30m").unwrap() + ); + } + + #[test] + fn test_parse_counter_time_hours_minutes_seconds() { + assert_eq!( + Duration::seconds(3723), + parse_counter_time("1h2m3s").unwrap() + ); + } + + #[test] + fn test_parse_counter_time_with_spaces() { + let result = parse_counter_time("1h 30m"); + if let Some(dur) = result { + assert_eq!(Duration::seconds(5400), dur); + } else { + assert_eq!(None, result); + } + } + #[test] fn test_parse_end_time() { let now = OffsetDateTime::now_local().ok().unwrap(); @@ -225,4 +336,171 @@ mod tests { let expected_date = now.replace_time(time!(13:45:43.123)); assert_eq!(date.to_hms_milli(), expected_date.to_hms_milli()); } + + #[test] + fn test_parse_end_time_midnight() { + let now = OffsetDateTime::now_local().ok().unwrap(); + let date = parse_end_time("00:00").unwrap(); + let expected_date = now.replace_time(time!(00:00)); + assert_eq!(date.to_hms(), expected_date.to_hms()); + } + + #[test] + fn test_parse_end_time_hms_max() { + let now = OffsetDateTime::now_local().ok().unwrap(); + let date = parse_end_time("23:59:59").unwrap(); + let expected_date = now.replace_time(time!(23:59:59)); + assert_eq!(date.to_hms(), expected_date.to_hms()); + } + + #[test] + fn test_parse_end_time_invalid() { + assert_eq!(None, parse_end_time("abc")); + } + + #[test] + fn test_parse_end_time_with_leading_zero() { + let now = OffsetDateTime::now_local().ok().unwrap(); + let date = parse_end_time("08:25").unwrap(); + let expected_date = now.replace_time(time!(08:25)); + assert_eq!(date.to_hms(), expected_date.to_hms()); + } + + #[test] + fn test_parse_end_time_hms_with_millis() { + let now = OffsetDateTime::now_local().ok().unwrap(); + let date = parse_end_time("13:45:43.999").unwrap(); + let expected_date = now.replace_time(time!(13:45:43.999)); + assert_eq!(date.to_hms_milli(), expected_date.to_hms_milli()); + } + + #[test] + fn test_countdown_silence_mode() { + use crate::opts::Opts; + + let mut buffer = Vec::new(); + let opts = Opts { + command: None, + r#loop: false, + silence: true, + terminal_bell: false, + time: vec!["1s".to_string()], + }; + + let beep_log = Arc::new(Mutex::new(AlertCallLog::new())); + let beep_alert = MockAlert::new(beep_log.clone(), "beep"); + let sound_alert = Arc::new(SilentAlert); + + let result = play_completion_alerts(&mut buffer, &opts, &beep_alert, sound_alert); + + assert!(result.is_ok()); + let log = beep_log.lock().unwrap(); + assert!(log.calls.is_empty()); + } + + #[test] + fn test_countdown_terminal_bell() { + use crate::opts::Opts; + + let mut buffer = Vec::new(); + let opts = Opts { + command: None, + r#loop: false, + silence: true, + terminal_bell: true, + time: vec!["1s".to_string()], + }; + + let beep_log = Arc::new(Mutex::new(AlertCallLog::new())); + let beep_alert = MockAlert::new(beep_log.clone(), "beep"); + let sound_alert = Arc::new(SilentAlert); + + let result = play_completion_alerts(&mut buffer, &opts, &beep_alert, sound_alert); + + assert!(result.is_ok()); + assert!(buffer.contains(&0x07)); + } + + #[test] + fn test_countdown_beep_called() { + use crate::opts::Opts; + + let mut buffer = Vec::new(); + let opts = Opts { + command: None, + r#loop: false, + silence: false, + terminal_bell: false, + time: vec!["1s".to_string()], + }; + + let beep_log = Arc::new(Mutex::new(AlertCallLog::new())); + let sound_log = Arc::new(Mutex::new(AlertCallLog::new())); + let beep_alert = MockAlert::new(beep_log.clone(), "beep"); + let sound_alert = Arc::new(MockAlert::new(sound_log.clone(), "sound")); + + let result = play_completion_alerts(&mut buffer, &opts, &beep_alert, sound_alert); + + assert!(result.is_ok()); + let log = beep_log.lock().unwrap(); + assert!(log.calls.contains(&"beep")); + } + + #[test] + fn test_countdown_sound_and_beep_parallel() { + use crate::opts::Opts; + + let mut buffer = Vec::new(); + let opts = Opts { + command: None, + r#loop: false, + silence: false, + terminal_bell: false, + time: vec!["1s".to_string()], + }; + + let beep_log = Arc::new(Mutex::new(AlertCallLog::new())); + let sound_log = Arc::new(Mutex::new(AlertCallLog::new())); + let beep_alert = MockAlert::new(beep_log.clone(), "beep"); + let sound_alert = Arc::new(MockAlert::new(sound_log.clone(), "sound")); + + let result = play_completion_alerts(&mut buffer, &opts, &beep_alert, sound_alert); + + assert!(result.is_ok()); + let beep_calls = beep_log.lock().unwrap(); + let sound_calls = sound_log.lock().unwrap(); + assert!(beep_calls.calls.contains(&"beep")); + assert!(sound_calls.calls.contains(&"sound")); + } + + #[test] + fn test_countdown_sound_thread_panic_catches() { + use crate::opts::Opts; + + struct PanicAlert; + impl Alert for PanicAlert { + fn play(&self) -> Result<()> { + panic!("Sound thread panicked"); + } + } + + let mut buffer = Vec::new(); + let opts = Opts { + command: None, + r#loop: false, + silence: false, + terminal_bell: false, + time: vec!["1s".to_string()], + }; + + let beep_log = Arc::new(Mutex::new(AlertCallLog::new())); + let beep_alert = MockAlert::new(beep_log.clone(), "beep"); + let sound_alert = Arc::new(PanicAlert); + + let result = play_completion_alerts(&mut buffer, &opts, &beep_alert, sound_alert); + + assert!(result.is_err()); + let log = beep_log.lock().unwrap(); + assert!(log.calls.contains(&"beep")); + } } diff --git a/src/ui.rs b/src/ui.rs index 8dc7021..727a2c6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -120,7 +120,7 @@ where Ok(()) } -fn format_laps(laps: &[StdDuration]) -> String { +pub(crate) fn format_laps(laps: &[StdDuration]) -> String { laps.iter() .enumerate() .map(|(i, lap)| { @@ -161,3 +161,41 @@ where terminal::LeaveAlternateScreen ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_laps_empty() { + assert_eq!(format_laps(&[]), ""); + } + + #[test] + fn test_format_laps_single_seconds() { + let laps = vec![StdDuration::from_secs(5)]; + assert_eq!(format_laps(&laps), "[1] 5s"); + } + + #[test] + fn test_format_laps_single_minutes() { + let laps = vec![StdDuration::from_secs(90)]; + assert_eq!(format_laps(&laps), "[1] 1m 30s"); + } + + #[test] + fn test_format_laps_single_hours() { + let laps = vec![StdDuration::from_secs(3661)]; + assert_eq!(format_laps(&laps), "[1] 1h 1m 1s"); + } + + #[test] + fn test_format_laps_multiple() { + let laps = vec![ + StdDuration::from_secs(30), + StdDuration::from_secs(90), + StdDuration::from_secs(3661), + ]; + assert_eq!(format_laps(&laps), "[1] 30s [2] 1m 30s [3] 1h 1m 1s"); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..e6f26d6 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,66 @@ +use assert_cmd::Command; +use std::time::Duration; + +fn timer() -> Command { + Command::cargo_bin("timer").unwrap() +} + +#[test] +fn test_countdown_1s_exits_ok() { + timer() + .arg("--silence") + .arg("1s") + .timeout(Duration::from_secs(5)) + .assert() + .success(); +} + +#[test] +fn test_countdown_with_terminal_bell() { + timer() + .arg("-t") + .arg("1s") + .timeout(Duration::from_secs(5)) + .assert() + .success(); +} + +#[test] +fn test_invalid_argument_exits_with_error() { + timer() + .arg("foo") + .timeout(Duration::from_secs(5)) + .assert() + .failure(); +} + +#[test] +fn test_invalid_flag_exits_with_error() { + timer() + .arg("--invalid-flag") + .timeout(Duration::from_secs(5)) + .assert() + .failure(); +} + +// Skipping this test as it requires terminal features not available in CI/test environments +// #[test] +// fn test_stopwatch_starts_and_exits() { +// timer() +// .arg("stopwatch") +// .timeout(Duration::from_millis(500)) +// .write_stdin("q") +// .assert() +// .success(); +// } + +#[test] +fn test_loop_mode_runs_one_cycle() { + timer() + .arg("--loop") + .arg("--silence") + .arg("1s") + .timeout(Duration::from_secs(3)) + .assert() + .failure(); // Should fail due to timeout since loop continues indefinitely +}