From 84a75f45311f911badc316d73d557193aea6c76b Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sun, 22 Feb 2026 20:40:31 -0800 Subject: [PATCH] test(nbd): add integration tests for nbd graph command [9c1f2c] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 integration tests covering: empty store, independent tickets (no indentation), chain rendering (└──), single-ticket subtree (excludes unrelated), --json output (nodes/edges arrays), --json subtree scoping, --filter narrowing, and 3-char prefix resolution. Co-Authored-By: Claude Sonnet 4.6 --- nbd/.nbd/tickets/9c1f2c.md | 90 +++++++++++++++++++++ nbd/tests/integration.rs | 162 +++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 nbd/.nbd/tickets/9c1f2c.md diff --git a/nbd/.nbd/tickets/9c1f2c.md b/nbd/.nbd/tickets/9c1f2c.md new file mode 100644 index 0000000..bc1c9e7 --- /dev/null +++ b/nbd/.nbd/tickets/9c1f2c.md @@ -0,0 +1,90 @@ ++++ +title = "Tests for nbd graph command" +priority = 5 +status = "done" +ticket_type = "task" +dependencies = ["9ad11f"] ++++ +Add comprehensive unit and integration tests for the `nbd graph` command introduced across tickets `9c9ebe`, `e14172`, and `9ad11f`. + +## Unit tests (src/tests.rs) + +These test `graph.rs` and the rendering helpers in `display.rs` in isolation using in-memory `Ticket` values (no temp files needed). + +### graph.rs tests + +``` +test_graph_build_empty + TicketGraph::build(&[]) has no nodes. + +test_graph_roots_no_deps + Two tickets with no dependencies → both appear in roots(). + +test_graph_roots_with_chain + Ticket B depends on A → only A is a root. + +test_graph_subtree_linear + A → B → C; subtree("a") returns ["a", "b", "c"] (DFS order). + +test_graph_subtree_single + subtree of a leaf returns just that ID. + +test_graph_subtree_cycle + A.deps = [B], B.deps = [A] → subtree("a") returns both without panic/infinite loop. + +test_graph_to_json_nodes_and_edges + Three tickets with two edges → JSON "nodes" has 3 entries, "edges" has 2. +``` + +### display.rs tests + +``` +test_format_graph_single_ticket + One ticket, no deps → output contains the ID, status, and title on one line; no connector chars. + +test_format_graph_two_ticket_chain + A → B; output has A at col 0 and B indented with "└──". + +test_format_graph_branching + A has two dependents B and C; B's line uses "├──" and C's uses "└──". + +test_format_subtree_scope + A → B, C (unrelated root); format_subtree(graph, "a") does not contain C's ID. + +test_format_graph_cycle_label + Cycle present → output contains "[cycle]" and does not repeat infinitely. +``` + +## Integration tests (tests/integration.rs) + +Use a real temp directory set up with `cargo run -- init` and tickets created via `cmd_create`. + +``` +test_graph_empty_store + `nbd graph` on an empty store produces an empty line (or at least exits 0). + +test_graph_all_no_deps + Create two tickets without deps; `nbd graph` output contains both IDs with no indentation. + +test_graph_chain + Create A (no deps) and B (--deps A); `nbd graph` output shows A at top level and B indented. + +test_graph_single_ticket + `nbd graph ` for A returns only A and its subtree, not unrelated tickets. + +test_graph_json_output + `nbd graph --json` parses as valid JSON with "nodes" and "edges" arrays. + +test_graph_json_subtree + `nbd graph --json` returns JSON whose "nodes" array contains only reachable tickets. + +test_graph_filter + `nbd graph --filter type=bug` only shows bug tickets and their reachable deps. + +test_graph_partial_id + `nbd graph <3-char-prefix>` resolves to the correct ticket (prefix resolution). +``` + +## Files touched +- `src/tests.rs` — unit tests for `TicketGraph` and `format_graph` / `format_subtree` +- `tests/integration.rs` — CLI-level integration tests \ No newline at end of file diff --git a/nbd/tests/integration.rs b/nbd/tests/integration.rs index ec5279e..075b158 100644 --- a/nbd/tests/integration.rs +++ b/nbd/tests/integration.rs @@ -1634,3 +1634,165 @@ fn next_filter_no_match_returns_null() { "no ticket has priority 99 so next should be null" ); } + +// ── graph tests ─────────────────────────────────────────────────────────────── + +/// `nbd graph` on an empty store exits 0 and produces an empty (or +/// whitespace-only) line. +#[test] +fn graph_empty_store() { + let env = TestEnv::new(); + let output = env.run(&["graph"]); + assert!( + output.status.success(), + "graph on empty store should exit 0: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// `nbd graph` with two independent tickets (no deps) shows both IDs with no +/// box-drawing indentation on either line. +#[test] +fn graph_all_no_deps() { + let env = TestEnv::new(); + let id_a = env.create(&["--title", "Alpha"]); + let id_b = env.create(&["--title", "Beta"]); + + let output = env.run(&["graph"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains(&id_a), "output should contain id_a"); + assert!(stdout.contains(&id_b), "output should contain id_b"); + // Neither ticket should have a connector (they are both roots). + let line_a = stdout.lines().find(|l| l.contains(&id_a)).unwrap(); + let line_b = stdout.lines().find(|l| l.contains(&id_b)).unwrap(); + assert!( + !line_a.contains("├──") && !line_a.contains("└──"), + "root ticket should have no connector: {line_a}" + ); + assert!( + !line_b.contains("├──") && !line_b.contains("└──"), + "root ticket should have no connector: {line_b}" + ); +} + +/// When B depends on A, `nbd graph` shows A at the top level and B indented +/// below it with `└──`. +#[test] +fn graph_chain() { + let env = TestEnv::new(); + let id_a = env.create(&["--title", "Alpha"]); + let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); + + let output = env.run(&["graph"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + + // A should appear before B. + let pos_a = stdout.find(&id_a).expect("id_a not found"); + let pos_b = stdout.find(&id_b).expect("id_b not found"); + assert!(pos_a < pos_b, "root (A) should appear before dependent (B)"); + + // B's line should use `└──`. + let line_b = stdout.lines().find(|l| l.contains(&id_b)).unwrap(); + assert!(line_b.contains("└──"), "child should use └── connector: {line_b}"); +} + +/// `nbd graph ` for ticket A shows A and its dependents but not unrelated +/// tickets. +#[test] +fn graph_single_ticket_subtree() { + let env = TestEnv::new(); + let id_a = env.create(&["--title", "Alpha"]); + let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); + let id_c = env.create(&["--title", "Gamma"]); // unrelated + + let output = env.run(&["graph", &id_a]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains(&id_a), "root should be in subtree"); + assert!(stdout.contains(&id_b), "dependent should be in subtree"); + assert!(!stdout.contains(&id_c), "unrelated ticket should be absent: {stdout}"); +} + +/// `nbd graph --json` produces valid JSON with `nodes` and `edges` arrays. +#[test] +fn graph_json_output() { + let env = TestEnv::new(); + let id_a = env.create(&["--title", "Alpha"]); + let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); + + let output = env.run(&["graph", "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).expect("--json should produce valid JSON"); + + let nodes = parsed["nodes"].as_array().expect("nodes should be an array"); + let edges = parsed["edges"].as_array().expect("edges should be an array"); + + let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); + assert!(node_ids.contains(&id_a.as_str()), "nodes should include id_a"); + assert!(node_ids.contains(&id_b.as_str()), "nodes should include id_b"); + assert_eq!(edges.len(), 1, "should have exactly one edge (A blocks B)"); + assert_eq!(edges[0]["from"].as_str().unwrap(), id_a); + assert_eq!(edges[0]["to"].as_str().unwrap(), id_b); +} + +/// `nbd graph --json` returns only nodes and edges reachable from . +#[test] +fn graph_json_subtree() { + let env = TestEnv::new(); + let id_a = env.create(&["--title", "Alpha"]); + let id_b = env.create(&["--title", "Beta", "--deps", &id_a]); + let id_c = env.create(&["--title", "Gamma"]); // unrelated + + let output = env.run(&["graph", &id_a, "--json"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + + let nodes = parsed["nodes"].as_array().unwrap(); + let node_ids: Vec<&str> = nodes.iter().map(|n| n["id"].as_str().unwrap()).collect(); + assert!(node_ids.contains(&id_a.as_str())); + assert!(node_ids.contains(&id_b.as_str())); + assert!( + !node_ids.contains(&id_c.as_str()), + "unrelated ticket should be excluded from JSON subtree" + ); +} + +/// `nbd graph --filter type=bug` only includes bug tickets and their dependents. +#[test] +fn graph_filter() { + let env = TestEnv::new(); + let id_bug = env.create(&["--title", "A bug", "--type", "bug"]); + let id_task = env.create(&["--title", "A task", "--type", "task"]); // unrelated + + let output = env.run(&["graph", "--filter", "type=bug"]); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(stdout.contains(&id_bug), "bug ticket should be visible"); + assert!(!stdout.contains(&id_task), "task ticket should be excluded: {stdout}"); +} + +/// `nbd graph <3-char-prefix>` resolves to the correct ticket using prefix +/// matching. +#[test] +fn graph_partial_id() { + let env = TestEnv::new(); + let id_a = env.create(&["--title", "Alpha"]); + let prefix = &id_a[..3]; + + let output = env.run(&["graph", prefix]); + assert!( + output.status.success(), + "graph with prefix should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains(&id_a), "resolved ticket should appear in output"); +}