Skip to content

Commit 26e5a92

Browse files
committed
test(cli): add comprehensive CLI integration and unit tests
- add integration tests covering activity flow, error scenarios, and CLI output validation using assert_cmd, predicates, and tempfile. - add unit tests for formatting, parsing, and edge case handling in models and utility modules. - fix command argument handling and config injection in tests to use `BOAT_CONFIG` env var. - update error message assertions for compatibility with current clap output. - ensure robust duration formatting and corresponding expectation in tests.
1 parent e198c4c commit 26e5a92

10 files changed

Lines changed: 614 additions & 11 deletions

File tree

Cargo.lock

Lines changed: 359 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "boat-cli"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
edition = "2024"
55
description = "Basic Opinionated Activity Tracker, a command line interface inspired by bartib"
66
repository = "https://github.com/coko7/boat"
@@ -30,6 +30,11 @@ boat-lib = "0.4.0"
3030
tabular = { version = "0.2.0", features = ["ansi-cell"] }
3131
yansi = "1.0.1"
3232

33+
[dev-dependencies]
34+
assert_cmd = "2.0"
35+
predicates = "3.0"
36+
tempfile = "3.8"
37+
3338
[features]
3439
default = []
3540
bundled-sqlite = ["boat-lib/bundled-sqlite"]

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
`boat` - A **B**asic **O**pinionated **A**ctivity **T**racker, inspired by [bartib](https://github.com/nikolassv/bartib).
44

55
Like its name implies, `boat` allows you to track the time you spend on everyday tasks.
6+
67
It has mainly been designed to be easy to embed in custom bash scripts so that you can augment it with fuzzy-finding.
7-
That said, if you plan to use the CLI directly (without external scripts), it also benefits from a [variety of handy aliases](#usage).
8+
That said, if you plan to use the CLI directly (without external scripts), it also benefits from a [variety of handy aliases](#-usage).
9+
810
`boat` stores its data in a SQLite database file which is kept in the config directory by default (`.config/boat/boat.db`).
911

1012
This repository contains only the code for the command line application.

src/models/activity.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ impl PrintableActivity {
3434
}
3535
}
3636

37+
#[cfg(test)]
38+
mod tests {
39+
use super::*;
40+
41+
#[test]
42+
fn tags_str_renders_comma_separated() {
43+
let mut tags = HashSet::new();
44+
tags.insert("foo".to_owned());
45+
tags.insert("bar".to_owned());
46+
47+
let act = PrintableActivity {
48+
id: 42,
49+
name: "n".to_owned(),
50+
description: None,
51+
ongoing: false,
52+
tags,
53+
};
54+
let tags_str = act.tags_str();
55+
56+
assert!(tags_str.contains("foo"));
57+
assert!(tags_str.contains("bar"));
58+
assert!(tags_str.find(',').is_some());
59+
}
60+
}
61+
3762
impl RowPrintable for PrintableActivity {
3863
fn row_spec() -> String {
3964
"{:>} {:<} {:<} {:<} {:^}".to_string()

src/models/log.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,29 @@ impl PrintableLog {
1515
ends_at: log.ends_at.map(|t| t.with_timezone(&Local)),
1616
}
1717
}
18-
1918
pub fn duration_sec(&self) -> i64 {
2019
let end = self.ends_at.unwrap_or(Local::now());
2120
(end - self.starts_at).num_seconds()
2221
}
2322
}
23+
24+
#[cfg(test)]
25+
mod tests {
26+
use super::*;
27+
28+
#[test]
29+
fn test_duration_sec() {
30+
let now = Local::now();
31+
let log = PrintableLog {
32+
starts_at: now,
33+
ends_at: Some(now + chrono::Duration::seconds(60)),
34+
};
35+
assert_eq!(log.duration_sec(), 60);
36+
37+
let log = PrintableLog {
38+
starts_at: now,
39+
ends_at: None,
40+
};
41+
assert!(log.duration_sec() >= 0); // Should not panic
42+
}
43+
}

src/models/tag.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@ impl PrintableTag {
1919
}
2020
}
2121

22+
#[cfg(test)]
23+
mod tests {
24+
use super::*;
25+
#[test]
26+
fn parse_from_tag_works() {
27+
let db_tag = DatabaseTag {
28+
id: 1,
29+
name: "foo".to_string(),
30+
};
31+
let tag = PrintableTag::from_tag(&db_tag);
32+
assert_eq!(tag.id, 1);
33+
assert_eq!(tag.name, "foo");
34+
}
35+
}
36+
2237
impl RowPrintable for PrintableTag {
2338
fn row_spec() -> String {
2439
"{:>} {:<}".to_string()

src/utils/date.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,33 @@ pub fn pretty_format_duration(mut seconds: i64) -> String {
1313
parts.push(format!("{hours}h"));
1414
}
1515

16-
if minutes > 0 || hours > 0 {
16+
if minutes > 0 {
1717
parts.push(format!("{minutes}m"));
1818
}
1919

20-
if parts.len() < 2 && (seconds > 0 || parts.is_empty()) {
20+
if hours == 0 && minutes == 0 {
2121
parts.push(format!("{seconds}s"));
2222
}
2323

2424
parts.join(" ")
2525
}
2626

27+
#[cfg(test)]
28+
mod tests {
29+
use super::*;
30+
31+
#[test]
32+
fn test_pretty_format_duration() {
33+
assert_eq!(&pretty_format_duration(0), "0s");
34+
assert_eq!(&pretty_format_duration(42), "42s");
35+
assert_eq!(&pretty_format_duration(60), "1m");
36+
assert_eq!(&pretty_format_duration(61), "1m");
37+
assert_eq!(&pretty_format_duration(3600), "1h");
38+
assert_eq!(&pretty_format_duration(3601), "1h");
39+
assert_eq!(&pretty_format_duration(3661), "1h 1m");
40+
}
41+
}
42+
2743
pub enum DateTimeRenderMode {
2844
TimeOnly,
2945
DateOnly,
@@ -151,3 +167,21 @@ pub fn parse_date(s: &str) -> Result<NaiveDate, String> {
151167
NaiveDate::parse_from_str(s, "%Y-%m-%d")
152168
.map_err(|_| format!("invalid date '{s}', expected format YYYY-MM-DD"))
153169
}
170+
171+
#[cfg(test)]
172+
mod parse_date_tests {
173+
use super::*;
174+
175+
#[test]
176+
fn parse_date_valid_should_succeed() {
177+
assert_eq!(
178+
parse_date("2023-08-14").unwrap(),
179+
NaiveDate::from_ymd_opt(2023, 8, 14).unwrap()
180+
);
181+
}
182+
#[test]
183+
fn parse_date_fails_invalid_should_fail() {
184+
let e = parse_date("nope").unwrap_err();
185+
assert!(e.contains("invalid date"));
186+
}
187+
}

tests/cli_activity_flow.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//! Test basic activity CRUD and flow in the CLI
2+
use assert_cmd::Command;
3+
use predicates::prelude::*;
4+
use std::fs;
5+
use tempfile::TempDir;
6+
7+
/// Helper to spin up a temp config + db directory and return required CLI args
8+
fn cli_args_for_temp() -> (TempDir, String) {
9+
let tmp = TempDir::new().unwrap();
10+
let db_path = tmp.path().join("boat.db");
11+
let config_path = tmp.path().join("boat_config.toml");
12+
fs::write(
13+
&config_path,
14+
format!("database_path = {:?}", db_path.display()),
15+
)
16+
.unwrap();
17+
(tmp, config_path.display().to_string())
18+
}
19+
20+
#[test]
21+
fn can_create_start_pause_list_activity() {
22+
let (_tmp, config_path) = cli_args_for_temp();
23+
24+
// boat new
25+
let mut cmd = Command::cargo_bin("boat").unwrap();
26+
cmd.env("BOAT_CONFIG", &config_path)
27+
.arg("new")
28+
.arg("TestTask");
29+
cmd.assert().success();
30+
31+
// boat start <ID: always 1 for first activity>
32+
let mut cmd = Command::cargo_bin("boat").unwrap();
33+
cmd.env("BOAT_CONFIG", &config_path).arg("start").arg("1");
34+
cmd.assert().success();
35+
36+
// boat pause
37+
let mut cmd = Command::cargo_bin("boat").unwrap();
38+
cmd.env("BOAT_CONFIG", &config_path).arg("pause");
39+
cmd.assert().success().stdout(
40+
predicates::str::contains("stopped").or(predicates::str::contains("stopped activity")),
41+
);
42+
43+
// boat list --json, just check output contains the activity name 'TestTask'
44+
let mut cmd = Command::cargo_bin("boat").unwrap();
45+
cmd.env("BOAT_CONFIG", &config_path)
46+
.arg("list")
47+
.arg("--json");
48+
cmd.assert()
49+
.success()
50+
.stdout(predicates::str::contains("TestTask"));
51+
}

tests/cli_errors.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//! Tests for error/failure scenarios in the CLI
2+
use assert_cmd::Command;
3+
use predicates::prelude::*;
4+
use std::fs;
5+
use tempfile::TempDir;
6+
7+
fn cli_args_for_temp() -> (TempDir, String) {
8+
let tmp = TempDir::new().unwrap();
9+
let db_path = tmp.path().join("boat.db");
10+
let config_path = tmp.path().join("boat_config.toml");
11+
fs::write(
12+
&config_path,
13+
format!("database_path = {:?}", db_path.display()),
14+
)
15+
.unwrap();
16+
(tmp, config_path.display().to_string())
17+
}
18+
19+
#[test]
20+
fn new_without_name_should_fail() {
21+
let (_tmp, config_path) = cli_args_for_temp();
22+
let mut cmd = Command::cargo_bin("boat").unwrap();
23+
cmd.env("BOAT_CONFIG", &config_path).arg("new");
24+
25+
// purposely omit activity name
26+
cmd.assert().failure().stderr(
27+
predicates::str::contains("error").or(predicates::str::contains("required arguments")),
28+
);
29+
}
30+
31+
#[test]
32+
fn list_mutually_exclusive_args_should_fail() {
33+
let (_tmp, config_path) = cli_args_for_temp();
34+
let mut cmd = Command::cargo_bin("boat").unwrap();
35+
36+
cmd.env("BOAT_CONFIG", &config_path)
37+
.arg("list")
38+
.arg("--period")
39+
.arg("today")
40+
.arg("--date")
41+
.arg("2024-05-01");
42+
cmd.assert()
43+
.failure()
44+
.stderr(predicates::str::contains("cannot be used with"));
45+
}
46+
47+
#[test]
48+
fn list_with_invalid_date_input_should_fail() {
49+
let (_tmp, config_path) = cli_args_for_temp();
50+
let mut cmd = Command::cargo_bin("boat").unwrap();
51+
52+
cmd.env("BOAT_CONFIG", &config_path)
53+
.arg("list")
54+
.arg("--date")
55+
.arg("not-a-date");
56+
cmd.assert()
57+
.failure()
58+
.stderr(predicates::str::contains("invalid date"));
59+
}

tests/cli_smoke.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//! Basic smoke tests: help, version, invalid command
2+
use assert_cmd::Command;
3+
use predicates::prelude::PredicateBooleanExt;
4+
5+
#[test]
6+
fn test_help_arg() {
7+
let mut cmd = Command::cargo_bin("boat").unwrap();
8+
cmd.arg("--help");
9+
cmd.assert()
10+
.success()
11+
.stdout(predicates::str::contains("Usage").or(predicates::str::contains("USAGE")));
12+
}
13+
14+
#[test]
15+
fn test_help_subcommand_short_alias() {
16+
let mut cmd = Command::cargo_bin("boat").unwrap();
17+
cmd.arg("h");
18+
cmd.assert()
19+
.success()
20+
.stdout(predicates::str::contains("Usage").or(predicates::str::contains("USAGE")));
21+
}
22+
23+
#[test]
24+
fn test_version_arg() {
25+
let mut cmd = Command::cargo_bin("boat").unwrap();
26+
cmd.arg("--version");
27+
cmd.assert()
28+
.success()
29+
.stdout(predicates::str::contains("boat"));
30+
}
31+
32+
#[test]
33+
fn test_unknown_subcommand_fails() {
34+
let mut cmd = Command::cargo_bin("boat").unwrap();
35+
cmd.arg("definitely-not-a-command");
36+
cmd.assert().failure().stderr(
37+
predicates::str::contains("error").or(predicates::str::contains("not a valid subcommand")),
38+
);
39+
}

0 commit comments

Comments
 (0)