From b173256cab311b0e0c9308cb156288956ff4ac25 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 25 Feb 2026 21:03:50 +0000 Subject: [PATCH] Add integration tests and update docs for next command Cover both scoring modes, verbose output, limit, empty case, and JSON output. Update SKILL.md and README.md. --- README.md | 1 + SKILL.md | 7 ++ tests/cli_next.rs | 215 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 tests/cli_next.rs diff --git a/README.md b/README.md index ebe19b68384392c80dc33d32db918accb7c61d6b..41f89e6d0985ed179c685569ec3c5df0598031a3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Commands: label Manage labels search Search tasks by title or description ready Show tasks with no open blockers + next Recommend next task(s) to work on stats Show task statistics (always JSON) compact Vacuum the database export Export tasks to JSONL (one JSON object per line) diff --git a/SKILL.md b/SKILL.md index 66ed6a7dcbc240c4b06c82be2423505ebdcb469b..00ffe5336d446a7c62885ecce3a41b5ba3674b0d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -82,4 +82,11 @@ td label list-all # What can be worked on right now? td ready # open with all blockers resolved td search "smtp" # substring match in title and description + +# What should I work on next? +td next # top 5 by critical path (default) +td next --mode effort # top 5 by effort-weighted scoring +td next --verbose # show scoring breakdown per task +td next -n 3 # limit to top 3 +td next --mode effort -v # combine flags ``` diff --git a/tests/cli_next.rs b/tests/cli_next.rs new file mode 100644 index 0000000000000000000000000000000000000000..31afe4b459bf38b59613478ede873c02d5091497 --- /dev/null +++ b/tests/cli_next.rs @@ -0,0 +1,215 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +fn td() -> Command { + Command::cargo_bin("td").unwrap() +} + +fn init_tmp() -> TempDir { + let tmp = TempDir::new().unwrap(); + td().arg("init").current_dir(&tmp).assert().success(); + tmp +} + +fn create_task(dir: &TempDir, title: &str, pri: &str, eff: &str) -> String { + let out = td() + .args(["--json", "create", title, "-p", pri, "-e", eff]) + .current_dir(dir) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + v["id"].as_str().unwrap().to_string() +} + +#[test] +fn next_no_open_tasks() { + let tmp = init_tmp(); + + td().arg("next") + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("No open tasks")); +} + +#[test] +fn next_single_task() { + let tmp = init_tmp(); + let id = create_task(&tmp, "Only task", "high", "low"); + + td().arg("next") + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains(&id)) + .stdout(predicate::str::contains("Only task")) + .stdout(predicate::str::contains("SCORE")); +} + +#[test] +fn next_impact_ranks_by_downstream() { + let tmp = init_tmp(); + // A blocks B. Same priority/effort. A should rank higher (unblocks B). + let a = create_task(&tmp, "Blocker", "medium", "medium"); + let b = create_task(&tmp, "Blocked", "medium", "medium"); + + td().args(["dep", "add", &b, &a]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td() + .args(["--json", "next"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let results = v.as_array().unwrap(); + + // Only A is ready (B is blocked). + assert_eq!(results.len(), 1); + assert_eq!(results[0]["id"].as_str().unwrap(), a); + assert!(results[0]["total_unblocked"].as_u64().unwrap() > 0); +} + +#[test] +fn next_effort_mode_prefers_low_effort() { + let tmp = init_tmp(); + // Both standalone. A is high-effort, B is low-effort. Same priority. + let a = create_task(&tmp, "Heavy", "medium", "high"); + let b = create_task(&tmp, "Light", "medium", "low"); + + let out = td() + .args(["--json", "next", "--mode", "effort"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let results = v.as_array().unwrap(); + + assert_eq!(results.len(), 2); + // B (low effort) should be ranked first. + assert_eq!(results[0]["id"].as_str().unwrap(), b); + assert_eq!(results[1]["id"].as_str().unwrap(), a); +} + +#[test] +fn next_verbose_shows_equation() { + let tmp = init_tmp(); + create_task(&tmp, "Task A", "high", "low"); + + td().args(["next", "--verbose"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("mode: impact")) + .stdout(predicate::str::contains("Unblocks:")); +} + +#[test] +fn next_verbose_effort_mode_shows_squared() { + let tmp = init_tmp(); + create_task(&tmp, "Task A", "high", "medium"); + + td().args(["next", "--verbose", "--mode", "effort"]) + .current_dir(&tmp) + .assert() + .success() + .stdout(predicate::str::contains("mode: effort")) + .stdout(predicate::str::contains("\u{00b2}")); +} + +#[test] +fn next_limit_truncates() { + let tmp = init_tmp(); + create_task(&tmp, "A", "high", "low"); + create_task(&tmp, "B", "medium", "medium"); + create_task(&tmp, "C", "low", "high"); + + let out = td() + .args(["--json", "next", "-n", "2"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 2); +} + +#[test] +fn next_json_empty() { + let tmp = init_tmp(); + + let out = td() + .args(["--json", "next"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 0); +} + +#[test] +fn next_invalid_mode_fails() { + let tmp = init_tmp(); + create_task(&tmp, "X", "medium", "medium"); + + td().args(["next", "--mode", "bogus"]) + .current_dir(&tmp) + .assert() + .failure() + .stderr(predicate::str::contains("invalid mode")); +} + +#[test] +fn next_transitive_chain_scores_correctly() { + let tmp = init_tmp(); + // A blocks B, B blocks C. Only A is ready. + let a = create_task(&tmp, "Root", "medium", "low"); + let b = create_task(&tmp, "Mid", "high", "medium"); + let c = create_task(&tmp, "Leaf", "low", "high"); + + td().args(["dep", "add", &b, &a]) + .current_dir(&tmp) + .assert() + .success(); + td().args(["dep", "add", &c, &b]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td() + .args(["--json", "next"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let results = v.as_array().unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0]["id"].as_str().unwrap(), a); + // Downstream: pw(B=high=3) + pw(C=low=1) = 4.0 + assert!((results[0]["downstream_score"].as_f64().unwrap() - 4.0).abs() < f64::EPSILON); + assert_eq!(results[0]["total_unblocked"].as_u64().unwrap(), 2); + assert_eq!(results[0]["direct_unblocked"].as_u64().unwrap(), 1); +} + +#[test] +fn next_ignores_closed_tasks() { + let tmp = init_tmp(); + let a = create_task(&tmp, "Open", "high", "low"); + let b = create_task(&tmp, "Closed", "high", "low"); + + td().args(["done", &b]).current_dir(&tmp).assert().success(); + + let out = td() + .args(["--json", "next"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let results = v.as_array().unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0]["id"].as_str().unwrap(), a); +}